=Major overhaul of MetaManager.php -> Meta.php and RestRouteManager.php -> Rest.php. Seems to work for JakeVan
1 files renamed
9 files added
121 files modified
6 files deleted
| | |
| | | |
| | | use JVBase\blocks\CustomBlocks; |
| | | use JVBase\integrations\BlueSky; |
| | | use JVBase\managers\cache\Cache; |
| | | use JVBase\managers\Cache; |
| | | use JVBase\managers\EmailManager; |
| | | use JVBase\managers\ErrorHandler; |
| | | use JVBase\managers\InvitationsManager; |
| | | use JVBase\managers\LoginManager; |
| | | use JVBase\managers\MagicLinkManager; |
| | | use JVBase\managers\queue\Queue; |
| | |
| | | use JVBase\rest\routes\ContentRoutes; |
| | | use JVBase\rest\routes\TermRoutes; |
| | | use JVBase\rest\routes\UploadRoutes; |
| | | use JVBase\rest\routes\BioRoutes; |
| | | //use JVBase\rest\routes\BioRoutes; |
| | | use JVBase\rest\routes\SettingsRoutes; |
| | | use JVBase\rest\routes\ShopRoutes; |
| | | //use JVBase\rest\routes\ShopRoutes; |
| | | use JVBase\rest\routes\SEORoutes; |
| | | use JVBase\rest\routes\QueueRoutes; |
| | | use JVBase\rest\routes\ErrorRoutes; |
| | |
| | | use JVBase\rest\routes\LoginRoutes; |
| | | use JVBase\rest\routes\NewsRoutes; |
| | | use JVBase\rest\routes\ReferralRoutes; |
| | | use JVBase\rest\routes\MagicLinkRoutes; |
| | | //use JVBase\rest\routes\MagicLinkRoutes; |
| | | use JVBase\rest\routes\ResponseRoutes; |
| | | use JVBase\rest\routes\OptionsRoutes; |
| | | use JVBase\rest\routes\VoteRoutes; |
| | | use JVBase\rest\routes\Invitations; |
| | | use JVBase\rest\routes\ApprovalRoutes; |
| | | use JVBase\rest\routes\AdminRoutes; |
| | | //use JVBase\rest\routes\AdminRoutes; |
| | | use JVBase\rest\routes\IntegrationsRoutes; |
| | | use JVBase\utility\Features; |
| | | |
| | |
| | | |
| | | class JVB |
| | | { |
| | | protected static JVB|null $instance = null; |
| | | protected array $managers = []; |
| | | protected array $content = []; |
| | | protected array $taxonomies = []; |
| | | protected static JVB|null $instance = null; |
| | | protected array $managers = []; |
| | | protected array $content = []; |
| | | protected array $taxonomies = []; |
| | | protected array $integrations = []; |
| | | protected array $blocks = []; |
| | | protected array $routes = []; |
| | | protected array $blocks = []; |
| | | protected array $routes = []; |
| | | protected CustomBlocks $customBlocks; |
| | | |
| | | protected array $serviceMap = [ |
| | |
| | | 'postmark' => 'JVBase\integrations\PostMark', |
| | | ]; |
| | | |
| | | public static function getInstance():JVB |
| | | { |
| | | if (self::$instance === null) { |
| | | self::$instance = new self(); |
| | | } |
| | | return self::$instance; |
| | | } |
| | | public static function getInstance(): JVB |
| | | { |
| | | if (self::$instance === null) { |
| | | self::$instance = new self(); |
| | | } |
| | | return self::$instance; |
| | | } |
| | | |
| | | |
| | | public function __construct() |
| | | { |
| | | public function __construct() |
| | | { |
| | | $this->customBlocks = new CustomBlocks(); |
| | | $this->managers = [ |
| | | 'errors' => new ErrorHandler(), |
| | | 'queue' => new Queue(), |
| | | $this->managers = [ |
| | | 'errors' => new ErrorHandler(), |
| | | 'queue' => new Queue(), |
| | | // 'dash' => new DashboardManager(), |
| | | 'roles' => new RoleManager(), |
| | | 'roles' => new RoleManager(), |
| | | // 'forms' => new FormManager(), |
| | | 'schema' => new SchemaOutputManager(), |
| | | 'admin' => new AdminPages(), |
| | | 'seoAdmin' => new SEOAdminPage(), |
| | | 'schema' => new SchemaOutputManager(), |
| | | 'admin' => new AdminPages(), |
| | | 'seoAdmin' => new SEOAdminPage(), |
| | | // 'uploads' => new UploadManager(), |
| | | 'userTerms' => new UserTermsManager(), |
| | | 'email' => new EmailManager(), |
| | | ]; |
| | | 'userTerms' => new UserTermsManager(), |
| | | 'email' => new EmailManager(), |
| | | ]; |
| | | |
| | | $this->routes = [ |
| | | 'login' => new LoginRoutes(), |
| | | 'integrations' => new IntegrationsRoutes(), |
| | | 'seo' => new SEORoutes(), |
| | | 'queue' => new QueueRoutes(), |
| | | 'settings' => new SettingsRoutes(), |
| | | 'upload' => new UploadRoutes(), |
| | | 'forms' => new FormRoutes() |
| | | 'login' => new LoginRoutes(), |
| | | 'integrations' => new IntegrationsRoutes(), |
| | | 'seo' => new SEORoutes(), |
| | | 'queue' => new QueueRoutes(), |
| | | 'settings' => new SettingsRoutes(), |
| | | 'upload' => new UploadRoutes(), |
| | | 'forms' => new FormRoutes() |
| | | ]; |
| | | |
| | | if (Features::forSite()->has('magicLink')) { |
| | | $this->routes['magicLink'] = new MagicLinkRoutes(); |
| | | // $this->routes['magicLink'] = new MagicLinkRoutes(); |
| | | $this->managers['magicLink'] = new MagicLinkManager(); |
| | | } |
| | | if (Features::forSite()->has('referrals')) { |
| | |
| | | $this->managers['dash'] = new DashboardManager(); |
| | | } |
| | | |
| | | if (Features::hasIntegration('square')){ |
| | | if (Features::hasIntegration('square')) { |
| | | $this->routes['square'] = new IntegrationsSquareRoutes(); |
| | | } |
| | | |
| | | if (Features::forSite()->has('feed_block')) { |
| | | $this->routes['feed'] = new FeedRoutes(); |
| | | } |
| | | if (jvbSiteHasNotifications()) { |
| | | if (Features::forSite()->has('feed_block')) { |
| | | $this->routes['feed'] = new FeedRoutes(); |
| | | } |
| | | if (jvbSiteHasNotifications()) { |
| | | $this->managers['notifications'] = new NotificationManager(); |
| | | $this->routes['notifications'] = new NotificationsRoutes(); |
| | | } |
| | | if (Features::forSite()->has('feed_block') || jvbSiteHasDashboard()) { |
| | | $this->routes['term'] = new TermRoutes(); |
| | | } |
| | | $this->routes['notifications'] = new NotificationsRoutes(); |
| | | } |
| | | if (Features::forSite()->has('feed_block') || jvbSiteHasDashboard()) { |
| | | $this->routes['term'] = new TermRoutes(); |
| | | } |
| | | |
| | | if (Features::forSite()->has('is_directory')) { |
| | | $this->managers['directory'] = new DirectoryManager(); |
| | | } |
| | | |
| | | if (jvbSiteHasDashboard()) { |
| | | $this->routes['error'] = new ErrorRoutes(); |
| | | $this->routes['admin'] = new AdminRoutes(); |
| | | $this->routes['content']= new ContentRoutes(); |
| | | $this->routes['bio'] = new BioRoutes(); |
| | | $this->routes['shop'] = new ShopRoutes(); |
| | | $this->routes['options']= new OptionsRoutes(); |
| | | } |
| | | if (jvbSiteHasDashboard()) { |
| | | $this->routes['error'] = new ErrorRoutes(); |
| | | // $this->routes['admin'] = new AdminRoutes(); |
| | | $this->routes['content'] = new ContentRoutes(); |
| | | // $this->routes['bio'] = new BioRoutes(); |
| | | // $this->routes['shop'] = new ShopRoutes(); |
| | | $this->routes['options'] = new OptionsRoutes(); |
| | | } |
| | | |
| | | if (jvbSiteHasFavourites()) { |
| | | $this->routes['favourites'] = new FavouritesRoutes(); |
| | | } |
| | | if (jvbSiteHasFavourites()) { |
| | | $this->routes['favourites'] = new FavouritesRoutes(); |
| | | } |
| | | |
| | | if (Features::forMembership()->has('forum')) { |
| | | $this->routes['news'] = new NewsRoutes(); |
| | | } |
| | | if (Features::anyContentHas('response') || Features::anyTaxonomyHas('response') || Features::anyUserHas('response')) { |
| | | $this->routes['comments'] = new ResponseRoutes(); |
| | | } |
| | | if (Features::anyContentHas('karma') || Features::anyTaxonomyHas('karma') || Features::anyUserHas('karma')) { |
| | | $this->routes['vote'] = new VoteRoutes(); |
| | | } |
| | | if (Features::anyContentHas('karma') || Features::anyTaxonomyHas('karma') || Features::anyUserHas('karma') |
| | | if (Features::forMembership()->has('forum')) { |
| | | $this->routes['news'] = new NewsRoutes(); |
| | | } |
| | | if (Features::forMembership()->has('invitable')) { |
| | | $this->managers['invitations'] = new InvitationsManager(); |
| | | } |
| | | if (Features::anyContentHas('response') || Features::anyTaxonomyHas('response') || Features::anyUserHas('response')) { |
| | | $this->routes['comments'] = new ResponseRoutes(); |
| | | } |
| | | if (Features::anyContentHas('karma') || Features::anyTaxonomyHas('karma') || Features::anyUserHas('karma')) { |
| | | $this->routes['vote'] = new VoteRoutes(); |
| | | } |
| | | if (Features::anyContentHas('karma') || Features::anyTaxonomyHas('karma') || Features::anyUserHas('karma') |
| | | || Features::forMembership()->has('member_verified') || |
| | | Features::forMembership()->has('term_approval')) { |
| | | $this->routes['approvals'] = new ApprovalRoutes(); |
| | | } |
| | | if (Features::forMembership()->has('can_invite')) { |
| | | $this->routes['invites'] = new Invitations(); |
| | | } |
| | | $this->routes['approvals'] = new ApprovalRoutes(); |
| | | } |
| | | if (Features::forMembership()->has('can_invite')) { |
| | | $this->routes['invites'] = new Invitations(); |
| | | } |
| | | |
| | | $this->setupIntegrations(); |
| | | |
| | | add_action('wp_footer', [$this, 'additionalActions']); |
| | | // $this->managers['notifications'] = new NotificationManager(); |
| | | // Register activation hook |
| | | register_activation_hook(JVB_DIR . '/jvb.php', [$this, 'activate']); |
| | | } |
| | | // Register activation hook |
| | | register_activation_hook(JVB_DIR . '/jvb.php', [$this, 'activate']); |
| | | } |
| | | |
| | | |
| | | protected function setupIntegrations():void |
| | | protected function setupIntegrations(): void |
| | | { |
| | | if (array_key_exists('integrations', JVB_SITE)){ |
| | | foreach (JVB_SITE['integrations'] as $service => $use){ |
| | | if (array_key_exists('integrations', JVB_SITE)) { |
| | | foreach (JVB_SITE['integrations'] as $service => $use) { |
| | | if (!$use) { |
| | | continue; |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | public function registeredContent():array |
| | | { |
| | | return array_merge(array_keys($this->content), array_keys($this->taxonomies)); |
| | | } |
| | | public function dashboard():DashboardManager|false |
| | | { |
| | | return $this->managers['dash']??false; |
| | | } |
| | | public function directories():DirectoryManager|false |
| | | public function registeredContent(): array |
| | | { |
| | | return $this->managers['directory']??false; |
| | | return array_merge(array_keys($this->content), array_keys($this->taxonomies)); |
| | | } |
| | | public function error():ErrorHandler |
| | | { |
| | | return $this->managers['errors']; |
| | | } |
| | | public function file() |
| | | { |
| | | return $this->managers['file']; |
| | | } |
| | | |
| | | public function queue():Queue |
| | | { |
| | | return $this->managers['queue']; |
| | | } |
| | | public function dashboard(): DashboardManager|false |
| | | { |
| | | return $this->managers['dash'] ?? false; |
| | | } |
| | | |
| | | public function directories(): DirectoryManager|false |
| | | { |
| | | return $this->managers['directory'] ?? false; |
| | | } |
| | | |
| | | public function error(): ErrorHandler |
| | | { |
| | | return $this->managers['errors']; |
| | | } |
| | | |
| | | public function file() |
| | | { |
| | | return $this->managers['file']; |
| | | } |
| | | |
| | | public function queue(): Queue |
| | | { |
| | | return $this->managers['queue']; |
| | | } |
| | | // public function forms() |
| | | // { |
| | | // return $this->managers['forms']; |
| | | // } |
| | | public function notification():NotificationManager|false |
| | | { |
| | | return $this->managers['notifications']??false; |
| | | } |
| | | public function routes($route):mixed |
| | | { |
| | | if (array_key_exists($route, $this->routes)) { |
| | | return $this->routes[$route]; |
| | | } |
| | | return false; |
| | | } |
| | | public function roles():RoleManager |
| | | { |
| | | return $this->managers['roles']; |
| | | } |
| | | public function admin() |
| | | { |
| | | return $this->managers['admin']; |
| | | } |
| | | public function notification(): NotificationManager|false |
| | | { |
| | | return $this->managers['notifications'] ?? false; |
| | | } |
| | | |
| | | public function routes($route): mixed |
| | | { |
| | | if (array_key_exists($route, $this->routes)) { |
| | | return $this->routes[$route]; |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | public function roles(): RoleManager |
| | | { |
| | | return $this->managers['roles']; |
| | | } |
| | | |
| | | public function admin() |
| | | { |
| | | return $this->managers['admin']; |
| | | } |
| | | |
| | | public function seoAdmin() |
| | | { |
| | | return $this->managers['seoAdmin']; |
| | | } |
| | | |
| | | public function getFields($type):array |
| | | { |
| | | $content = JVB_CONTENT[$type]??JVB_TAXONOMY[$type]??JVB_USER[$type]??[]; |
| | | return $content['fields']??[]; |
| | | } |
| | | public function getContent($type):mixed |
| | | { |
| | | return $this->content[$type]??$this->taxonomies[$type]??$this->blocks[$type]??null; |
| | | } |
| | | public function getFields($type): array |
| | | { |
| | | $content = JVB_CONTENT[$type] ?? JVB_TAXONOMY[$type] ?? JVB_USER[$type] ?? []; |
| | | return $content['fields'] ?? []; |
| | | } |
| | | |
| | | public function connect(string $service, ?int $userID = null):mixed |
| | | public function getContent($type): mixed |
| | | { |
| | | return $this->content[$type] ?? $this->taxonomies[$type] ?? $this->blocks[$type] ?? null; |
| | | } |
| | | |
| | | public function connect(string $service, ?int $userID = null): mixed |
| | | { |
| | | if ($userID) { |
| | | if (!$this->userCanConnect($service, $userID)){ |
| | | if (!$this->userCanConnect($service, $userID)) { |
| | | return null; |
| | | } |
| | | |
| | |
| | | } |
| | | return (array_key_exists($service, $this->integrations)) ? $this->integrations[$service] : null; |
| | | } |
| | | public function userCanConnect(string $service, int $userID):bool |
| | | |
| | | public function userCanConnect(string $service, int $userID): bool |
| | | { |
| | | $allowed = JVB_USER[jvbUserRole($userID)]['integrations'] ?? []; |
| | | return user_can($userID, 'manage_options') || in_array($service, $allowed); |
| | | } |
| | | public function getAvailableServices(bool $keys = true):array { |
| | | |
| | | public function getAvailableServices(bool $keys = true): array |
| | | { |
| | | |
| | | return ($keys) ? array_keys($this->integrations) : $this->integrations; |
| | | } |
| | | |
| | | public function activate():void |
| | | { |
| | | // Activate roles - will be properly initialized after post types are registered |
| | | $this->roles()->activate(); |
| | | } |
| | | public function activate(): void |
| | | { |
| | | // Activate roles - will be properly initialized after post types are registered |
| | | $this->roles()->activate(); |
| | | } |
| | | |
| | | public function addRoute($slug, $class):void |
| | | public function addRoute($slug, $class): void |
| | | { |
| | | $this->routes[$slug] = $class; |
| | | } |
| | | |
| | | public function email():EmailManager |
| | | public function email(): EmailManager |
| | | { |
| | | return $this->managers['email']; |
| | | } |
| | | |
| | | public function referrals():ReferralManager|false |
| | | public function referrals(): ReferralManager|false |
| | | { |
| | | return $this->managers['referral']??false; |
| | | return $this->managers['referral'] ?? false; |
| | | } |
| | | |
| | | public function magicLink():MagicLinkManager|false |
| | | public function magicLink(): MagicLinkManager|false |
| | | { |
| | | return $this->managers['magicLink']??false; |
| | | return $this->managers['magicLink'] ?? false; |
| | | } |
| | | |
| | | public function invitations(): InvitationsManager|false |
| | | { |
| | | return $this->managers['invitations'] ?? false; |
| | | } |
| | | |
| | | public function additionalActions():void |
| | |
| | | |
| | | function jvbActivatePlugin():void |
| | | { |
| | | ob_start(); |
| | | $validator = new JVBase\utility\Validator(); |
| | | $validation = $validator->validateAll(); |
| | | error_log('Validation result: '.print_r($validation, true)); |
| | |
| | | error_log('Adding Umami tables'); |
| | | Umami::createTables(); |
| | | } |
| | | |
| | | JVB()->directories()->activate(); |
| | | error_log('Activation done! Huzzah!'); |
| | | |
| | | $output = ob_get_clean(); |
| | | if ( $output ) { |
| | | // Grab a backtrace to see what caused the output |
| | | $trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ); |
| | | $formatted = []; |
| | | foreach ( $trace as $step ) { |
| | | if ( isset( $step['file'], $step['line'] ) ) { |
| | | $formatted[] = $step['file'] . ':' . $step['line']; |
| | | } |
| | | } |
| | | |
| | | error_log( "⚠️ Plugin activation produced unexpected output (" . strlen( $output ) . " chars)" ); |
| | | error_log( "Output: " . trim( $output ) ); |
| | | error_log( "Backtrace: " . implode( ' <- ', $formatted ) ); |
| | | } |
| | | } |
| | | |
| | | function jvbAddAdminCaps() |
| | |
| | | ARRAY_FILTER_USE_KEY |
| | | ); |
| | | foreach ($options as $key => $value) { |
| | | error_log('Deleting Option'.$key); |
| | | delete_option($key); |
| | | } |
| | | } |
| | |
| | | $pages = new WP_Query([ |
| | | 'post_type' => BASE.'directory', |
| | | 'posts_per_page' => -1, |
| | | 'post_status' => 'any', |
| | | 'post_status' => ['publish', 'draft','trash'], |
| | | 'fields' => 'ids' |
| | | ]); |
| | | if ($pages->have_posts()) { |
| | | foreach ($pages->posts as $ID) { |
| | | wp_delete_post($ID, true); |
| | | } |
| | | } |
| | | foreach ($pages->posts as $ID) { |
| | | wp_delete_post($ID, true); |
| | | } |
| | | |
| | | } |
| | | function jvbClearSchedules() |
| | | { |
| | |
| | | .icon-git-merge{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMTE0YTMwLDMwLDAsMCwwLTI5LjIxLDIzLjE5bC00NC02LjI4YTEwLDEwLDAsMCwxLTYuMTgtMy4zOUw5MS4xOCw4My44M0EzMCwzMCwwLDEsMCw3NCw4NS40djg1LjJhMzAsMzAsMCwxLDAsMTIsMFY5Ni4yMmwzMy41MiwzOS4xMWEyMiwyMiwwLDAsMCwxMy42LDcuNDZsNDUuMzUsNi40OEEzMCwzMCwwLDEsMCwyMDgsMTE0Wk02Miw1NkExOCwxOCwwLDEsMSw4MCw3NCwxOCwxOCwwLDAsMSw2Miw1NlpNOTgsMjAwYTE4LDE4LDAsMSwxLTE4LTE4QTE4LDE4LDAsMCwxLDk4LDIwMFptMTEwLTM4YTE4LDE4LDAsMSwxLDE4LTE4QTE4LDE4LDAsMCwxLDIwOCwxNjJaIi8+PC9zdmc+');}.icon-arrow-counter-clockwise{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsMTI4YTk0LDk0LDAsMCwxLTkyLjc0LDk0SDEyOGE5My40Myw5My40MywwLDAsMS02NC41LTI1LjY1LDYsNiwwLDEsMSw4LjI0LTguNzJBODIsODIsMCwxLDAsNzAsNzBsLS4xOS4xOUwzOS40NCw5OEg3MmE2LDYsMCwwLDEsMCwxMkgyNGE2LDYsMCwwLDEtNi02VjU2YTYsNiwwLDAsMSwxMiwwVjkwLjM0TDYxLjYzLDYxLjRBOTQsOTQsMCwwLDEsMjIyLDEyOFoiLz48L3N2Zz4=');}.icon-check{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjguMjQsNzYuMjRsLTEyOCwxMjhhNiw2LDAsMCwxLTguNDgsMGwtNTYtNTZhNiw2LDAsMCwxLDguNDgtOC40OEw5NiwxOTEuNTEsMjE5Ljc2LDY3Ljc2YTYsNiwwLDAsMSw4LjQ4LDguNDhaIi8+PC9zdmc+');}.icon-squares-four{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMDQsNDJINTZBMTQsMTQsMCwwLDAsNDIsNTZ2NDhhMTQsMTQsMCwwLDAsMTQsMTRoNDhhMTQsMTQsMCwwLDAsMTQtMTRWNTZBMTQsMTQsMCwwLDAsMTA0LDQyWm0yLDYyYTIsMiwwLDAsMS0yLDJINTZhMiwyLDAsMCwxLTItMlY1NmEyLDIsMCwwLDEsMi0yaDQ4YTIsMiwwLDAsMSwyLDJabTk0LTYySDE1MmExNCwxNCwwLDAsMC0xNCwxNHY0OGExNCwxNCwwLDAsMCwxNCwxNGg0OGExNCwxNCwwLDAsMCwxNC0xNFY1NkExNCwxNCwwLDAsMCwyMDAsNDJabTIsNjJhMiwyLDAsMCwxLTIsMkgxNTJhMiwyLDAsMCwxLTItMlY1NmEyLDIsMCwwLDEsMi0yaDQ4YTIsMiwwLDAsMSwyLDJabS05OCwzNEg1NmExNCwxNCwwLDAsMC0xNCwxNHY0OGExNCwxNCwwLDAsMCwxNCwxNGg0OGExNCwxNCwwLDAsMCwxNC0xNFYxNTJBMTQsMTQsMCwwLDAsMTA0LDEzOFptMiw2MmEyLDIsMCwwLDEtMiwySDU2YTIsMiwwLDAsMS0yLTJWMTUyYTIsMiwwLDAsMSwyLTJoNDhhMiwyLDAsMCwxLDIsMlptOTQtNjJIMTUyYTE0LDE0LDAsMCwwLTE0LDE0djQ4YTE0LDE0LDAsMCwwLDE0LDE0aDQ4YTE0LDE0LDAsMCwwLDE0LTE0VjE1MkExNCwxNCwwLDAsMCwyMDAsMTM4Wm0yLDYyYTIsMiwwLDAsMS0yLDJIMTUyYTIsMiwwLDAsMS0yLTJWMTUyYTIsMiwwLDAsMSwyLTJoNDhhMiwyLDAsMCwxLDIsMloiLz48L3N2Zz4=');}.icon-rows{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMTM4SDQ4YTE0LDE0LDAsMCwwLTE0LDE0djQwYTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFYxNTJBMTQsMTQsMCwwLDAsMjA4LDEzOFptMiw1NGEyLDIsMCwwLDEtMiwySDQ4YTIsMiwwLDAsMS0yLTJWMTUyYTIsMiwwLDAsMSwyLTJIMjA4YTIsMiwwLDAsMSwyLDJaTTIwOCw1MEg0OEExNCwxNCwwLDAsMCwzNCw2NHY0MGExNCwxNCwwLDAsMCwxNCwxNEgyMDhhMTQsMTQsMCwwLDAsMTQtMTRWNjRBMTQsMTQsMCwwLDAsMjA4LDUwWm0yLDU0YTIsMiwwLDAsMS0yLDJINDhhMiwyLDAsMCwxLTItMlY2NGEyLDIsMCwwLDEsMi0ySDIwOGEyLDIsMCwwLDEsMiwyWiIvPjwvc3ZnPg==');}.icon-table{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjQsNTBIMzJhNiw2LDAsMCwwLTYsNlYxOTJhMTQsMTQsMCwwLDAsMTQsMTRIMjE2YTE0LDE0LDAsMCwwLDE0LTE0VjU2QTYsNiwwLDAsMCwyMjQsNTBaTTM4LDExMEg4MnYzNkgzOFptNTYsMEgyMTh2MzZIOTRaTTIxOCw2MlY5OEgzOFY2MlpNMzgsMTkyVjE1OEg4MnYzNkg0MEEyLDIsMCwwLDEsMzgsMTkyWm0xNzgsMkg5NFYxNThIMjE4djM0QTIsMiwwLDAsMSwyMTYsMTk0WiIvPjwvc3ZnPg==');}.icon-infinity{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsMTI4YTU0LDU0LDAsMCwxLTkyLjE4LDM4LjE4LDMuMDcsMy4wNywwLDAsMS0uMjUtLjI2bC02MC02Ny43NGE0Miw0MiwwLDEsMCwwLDU5LjY0bDguNTctOS42N2E2LDYsMCwxLDEsOSw4bC04LjY5LDkuODFhMy4wNywzLjA3LDAsMCwxLS4yNS4yNiw1NCw1NCwwLDEsMSwwLTc2LjM2LDMuMDcsMy4wNywwLDAsMSwuMjUuMjZsNjAsNjcuNzRhNDIsNDIsMCwxLDAsMC01OS42NGwtOC41Nyw5LjY3YTYsNiwwLDEsMS05LThsOC42OS05LjgxYTMuMDcsMy4wNywwLDAsMSwuMjUtLjI2QTU0LDU0LDAsMCwxLDI0NiwxMjhaIi8+PC9zdmc+');}.icon-eye{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDUuNDgsMTI1LjU3Yy0uMzQtLjc4LTguNjYtMTkuMjMtMjcuMjQtMzcuODFDMjAxLDcwLjU0LDE3MS4zOCw1MCwxMjgsNTBTNTUsNzAuNTQsMzcuNzYsODcuNzZjLTE4LjU4LDE4LjU4LTI2LjksMzctMjcuMjQsMzcuODFhNiw2LDAsMCwwLDAsNC44OGMuMzQuNzcsOC42NiwxOS4yMiwyNy4yNCwzNy44QzU1LDE4NS40Nyw4NC42MiwyMDYsMTI4LDIwNnM3My0yMC41Myw5MC4yNC0zNy43NWMxOC41OC0xOC41OCwyNi45LTM3LDI3LjI0LTM3LjhBNiw2LDAsMCwwLDI0NS40OCwxMjUuNTdaTTEyOCwxOTRjLTMxLjM4LDAtNTguNzgtMTEuNDItODEuNDUtMzMuOTNBMTM0Ljc3LDEzNC43NywwLDAsMSwyMi42OSwxMjgsMTM0LjU2LDEzNC41NiwwLDAsMSw0Ni41NSw5NS45NEM2OS4yMiw3My40Miw5Ni42Miw2MiwxMjgsNjJzNTguNzgsMTEuNDIsODEuNDUsMzMuOTRBMTM0LjU2LDEzNC41NiwwLDAsMSwyMzMuMzEsMTI4QzIyNi45NCwxNDAuMjEsMTk1LDE5NCwxMjgsMTk0Wm0wLTExMmE0Niw0NiwwLDEsMCw0Niw0NkE0Ni4wNiw0Ni4wNiwwLDAsMCwxMjgsODJabTAsODBhMzQsMzQsMCwxLDEsMzQtMzRBMzQsMzQsMCwwLDEsMTI4LDE2MloiLz48L3N2Zz4=');}.icon-eye-slash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik01Mi40NCwzNkE2LDYsMCwwLDAsNDMuNTYsNDRMNjQuNDQsNjdjLTM3LjI4LDIxLjktNTMuMjMsNTctNTMuOTIsNTguNTdhNiw2LDAsMCwwLDAsNC44OGMuMzQuNzcsOC42NiwxOS4yMiwyNy4yNCwzNy44QzU1LDE4NS40Nyw4NC42MiwyMDYsMTI4LDIwNmExMjQuOTEsMTI0LjkxLDAsMCwwLDUyLjU3LTExLjI1bDIzLDI1LjI5YTYsNiwwLDAsMCw4Ljg4LTguMDhabTQ4LjYyLDcxLjMyLDQ1LDQ5LjUyYTM0LDM0LDAsMCwxLTQ1LTQ5LjUyWk0xMjgsMTk0Yy0zMS4zOCwwLTU4Ljc4LTExLjQyLTgxLjQ1LTMzLjkzQTEzNC41NywxMzQuNTcsMCwwLDEsMjIuNjksMTI4YzQuMjktOC4yLDIwLjEtMzUuMTgsNTAtNTEuOTFMOTIuODksOTguM2E0Niw0NiwwLDAsMCw2MS4zNSw2Ny40OGwxNy44MSwxOS42QTExMy40NywxMTMuNDcsMCwwLDEsMTI4LDE5NFptNi40LTk5LjRhNiw2LDAsMCwxLDIuMjUtMTEuNzksNDYuMTcsNDYuMTcsMCwwLDEsMzcuMTUsNDAuODcsNiw2LDAsMCwxLTUuNDIsNi41M2wtLjU2LDBhNiw2LDAsMCwxLTYtNS40NUEzNC4xLDM0LjEsMCwwLDAsMTM0LjQsOTQuNlptMTExLjA4LDM1Ljg1Yy0uNDEuOTItMTAuMzcsMjMtMzIuODYsNDMuMTJhNiw2LDAsMSwxLTgtOC45NEExMzQuMDcsMTM0LjA3LDAsMCwwLDIzMy4zMSwxMjhhMTM0LjY3LDEzNC42NywwLDAsMC0yMy44Ni0zMi4wN0MxODYuNzgsNzMuNDIsMTU5LjM4LDYyLDEyOCw2MmExMjAuMTksMTIwLjE5LDAsMCwwLTE5LjY5LDEuNiw2LDYsMCwxLDEtMi0xMS44M0ExMzEuMTIsMTMxLjEyLDAsMCwxLDEyOCw1MGM0My4zOCwwLDczLDIwLjU0LDkwLjI0LDM3Ljc2LDE4LjU4LDE4LjU4LDI2LjksMzcsMjcuMjQsMzcuODFBNiw2LDAsMCwxLDI0NS40OCwxMzAuNDVaIi8+PC9zdmc+');}.icon-columns{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMDQsMzRINjRBMTQsMTQsMCwwLDAsNTAsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0aDQwYTE0LDE0LDAsMCwwLDE0LTE0VjQ4QTE0LDE0LDAsMCwwLDEwNCwzNFptMiwxNzRhMiwyLDAsMCwxLTIsMkg2NGEyLDIsMCwwLDEtMi0yVjQ4YTIsMiwwLDAsMSwyLTJoNDBhMiwyLDAsMCwxLDIsMlpNMTkyLDM0SDE1MmExNCwxNCwwLDAsMC0xNCwxNFYyMDhhMTQsMTQsMCwwLDAsMTQsMTRoNDBhMTQsMTQsMCwwLDAsMTQtMTRWNDhBMTQsMTQsMCwwLDAsMTkyLDM0Wm0yLDE3NGEyLDIsMCwwLDEtMiwySDE1MmEyLDIsMCwwLDEtMi0yVjQ4YTIsMiwwLDAsMSwyLTJoNDBhMiwyLDAsMCwxLDIsMloiLz48L3N2Zz4=');}.icon-caret-double-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTIuMjQsMTMxLjc2YTYsNiwwLDAsMSwwLDguNDhsLTgwLDgwYTYsNiwwLDAsMS04LjQ4LDBsLTgwLTgwYTYsNiwwLDAsMSw4LjQ4LTguNDhMMTI4LDIwNy41MWw3NS43Ni03NS43NUE2LDYsMCwwLDEsMjEyLjI0LDEzMS43NlptLTg4LjQ4LDguNDhhNiw2LDAsMCwwLDguNDgsMGw4MC04MGE2LDYsMCwwLDAtOC40OC04LjQ4TDEyOCwxMjcuNTEsNTIuMjQsNTEuNzZhNiw2LDAsMCwwLTguNDgsOC40OFoiLz48L3N2Zz4=');}.icon-caret-double-right{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNDAuMjQsMTMyLjI0bC04MCw4MGE2LDYsMCwwLDEtOC40OC04LjQ4TDEyNy41MSwxMjgsNTEuNzYsNTIuMjRhNiw2LDAsMCwxLDguNDgtOC40OGw4MCw4MEE2LDYsMCwwLDEsMTQwLjI0LDEzMi4yNFptODAtOC40OC04MC04MGE2LDYsMCwwLDAtOC40OCw4LjQ4TDIwNy41MSwxMjhsLTc1Ljc1LDc1Ljc2YTYsNiwwLDEsMCw4LjQ4LDguNDhsODAtODBBNiw2LDAsMCwwLDIyMC4yNCwxMjMuNzZaIi8+PC9zdmc+');}.icon-door{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzIsMjE4SDIwNlY0MGExNCwxNCwwLDAsMC0xNC0xNEg2NEExNCwxNCwwLDAsMCw1MCw0MFYyMThIMjRhNiw2LDAsMCwwLDAsMTJIMjMyYTYsNiwwLDAsMCwwLTEyWk02Miw0MGEyLDIsMCwwLDEsMi0ySDE5MmEyLDIsMCwwLDEsMiwyVjIxOEg2MlptMTA0LDkyYTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDE2NiwxMzJaIi8+PC9zdmc+');}.icon-book-bookmark{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMjZINzJBMzAsMzAsMCwwLDAsNDIsNTZWMjI0YTYsNiwwLDAsMCw2LDZIMTkyYTYsNiwwLDAsMCwwLTEySDU0di0yYTE4LDE4LDAsMCwxLDE4LTE4SDIwOGE2LDYsMCwwLDAsNi02VjMyQTYsNiwwLDAsMCwyMDgsMjZaTTExOCwzOGg1MnY3OEwxNDcuNTksOTkuMmE2LDYsMCwwLDAtNy4yLDBMMTE4LDExNlptODQsMTQ4SDcyYTI5Ljg3LDI5Ljg3LDAsMCwwLTE4LDZWNTZBMTgsMTgsMCwwLDEsNzIsMzhoMzR2OTBhNiw2LDAsMCwwLDkuNiw0LjhMMTQ0LDExMS41bDI4LjQxLDIxLjNBNiw2LDAsMCwwLDE4MiwxMjhWMzhoMjBaIi8+PC9zdmc+');}.icon-faders{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMzQsMTIwdjk2YTYsNiwwLDAsMS0xMiwwVjEyMGE2LDYsMCwwLDEsMTIsMFptNjYsNzRhNiw2LDAsMCwwLTYsNnYxNmE2LDYsMCwwLDAsMTIsMFYyMDBBNiw2LDAsMCwwLDIwMCwxOTRabTI0LTMySDIwNlY0MGE2LDYsMCwwLDAtMTIsMFYxNjJIMTc2YTYsNiwwLDAsMCwwLDEyaDQ4YTYsNiwwLDAsMCwwLTEyWk01NiwxNjJhNiw2LDAsMCwwLTYsNnY0OGE2LDYsMCwwLDAsMTIsMFYxNjhBNiw2LDAsMCwwLDU2LDE2MlptMjQtMzJINjJWNDBhNiw2LDAsMCwwLTEyLDB2OTBIMzJhNiw2LDAsMCwwLDAsMTJIODBhNiw2LDAsMCwwLDAtMTJabTcyLTQ4SDEzNFY0MGE2LDYsMCwwLDAtMTIsMFY4MkgxMDRhNiw2LDAsMCwwLDAsMTJoNDhhNiw2LDAsMCwwLDAtMTJaIi8+PC9zdmc+');}.icon-robot{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDAsNTBIMTM0VjE2YTYsNiwwLDAsMC0xMiwwVjUwSDU2QTMwLDMwLDAsMCwwLDI2LDgwVjE5MmEzMCwzMCwwLDAsMCwzMCwzMEgyMDBhMzAsMzAsMCwwLDAsMzAtMzBWODBBMzAsMzAsMCwwLDAsMjAwLDUwWm0xOCwxNDJhMTgsMTgsMCwwLDEtMTgsMThINTZhMTgsMTgsMCwwLDEtMTgtMThWODBBMTgsMTgsMCwwLDEsNTYsNjJIMjAwYTE4LDE4LDAsMCwxLDE4LDE4Wk03NCwxMDhhMTAsMTAsMCwxLDEsMTAsMTBBMTAsMTAsMCwwLDEsNzQsMTA4Wm04OCwwYTEwLDEwLDAsMSwxLDEwLDEwQTEwLDEwLDAsMCwxLDE2MiwxMDhabTIsMzBIOTJhMjYsMjYsMCwwLDAsMCw1Mmg3MmEyNiwyNiwwLDAsMCwwLTUyWm0tMjIsMTJ2MjhIMTE0VjE1MFpNNzgsMTY0YTE0LDE0LDAsMCwxLDE0LTE0aDEwdjI4SDkyQTE0LDE0LDAsMCwxLDc4LDE2NFptODYsMTRIMTU0VjE1MGgxMGExNCwxNCwwLDAsMSwwLDI4WiIvPjwvc3ZnPg==');}.icon-plugs-connected{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzYuMjQsMTkuNzZhNiw2LDAsMCwwLTguNDgsMEwxNzMuOTQsNzMuNTdsLTYuNzktNi43OGEzMCwzMCwwLDAsMC00Mi40MiwwTDEwMCw5MS41MWwtNy43Ni03Ljc1YTYsNiwwLDAsMC04LjQ4LDguNDhMOTEuNTEsMTAwLDY2Ljc5LDEyNC43M2EzMCwzMCwwLDAsMCwwLDQyLjQybDYuNzgsNi43OUwxOS43NiwyMjcuNzZhNiw2LDAsMSwwLDguNDgsOC40OGw1My44Mi01My44MSw2Ljc5LDYuNzhhMzAsMzAsMCwwLDAsNDIuNDIsMEwxNTYsMTY0LjQ5bDcuNzYsNy43NWE2LDYsMCwwLDAsOC40OC04LjQ4TDE2NC40OSwxNTZsMjQuNzItMjQuNzNhMzAsMzAsMCwwLDAsMC00Mi40MmwtNi43OC02Ljc5LDUzLjgxLTUzLjgyQTYsNiwwLDAsMCwyMzYuMjQsMTkuNzZabS0xMTMuNDUsMTYxYTE4LDE4LDAsMCwxLTI1LjQ2LDBMNzUuMjcsMTU4LjY3YTE4LDE4LDAsMCwxLDAtMjUuNDZMMTAwLDEwOC40OSwxNDcuNTEsMTU2Wm01Ny45NC01Ny45NEwxNTYsMTQ3LjUxLDEwOC40OSwxMDBsMjQuNzItMjQuNzNhMTgsMTgsMCwwLDEsMjUuNDYsMGwyMi4wNiwyMi4wNmExOCwxOCwwLDAsMSwwLDI1LjQ2Wk05MC40MywzNC4yM2E2LDYsMCwwLDEsMTEuMTQtNC40Nmw4LDIwYTYsNiwwLDEsMS0xMS4xNCw0LjQ2Wm0tNjQsNTkuNTRhNiw2LDAsMCwxLDcuOC0zLjM0bDIwLDhhNiw2LDAsMSwxLTQuNDYsMTEuMTRsLTIwLThBNiw2LDAsMCwxLDI2LjQzLDkzLjc3Wm0yMDMuMTQsNjguNDZhNiw2LDAsMCwxLTcuOCwzLjM0bC0yMC04YTYsNiwwLDAsMSw0LjQ2LTExLjE0bDIwLDhBNiw2LDAsMCwxLDIyOS41NywxNjIuMjNabS02NCw1OS41NGE2LDYsMCwxLDEtMTEuMTQsNC40NmwtOC0yMGE2LDYsMCwwLDEsMTEuMTQtNC40NloiLz48L3N2Zz4=');}.icon-user-circle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjgsMjZBMTAyLDEwMiwwLDEsMCwyMzAsMTI4LDEwMi4xMiwxMDIuMTIsMCwwLDAsMTI4LDI2Wk03MS40NCwxOThhNjYsNjYsMCwwLDEsMTEzLjEyLDAsODkuOCw4OS44LDAsMCwxLTExMy4xMiwwWk05NCwxMjBhMzQsMzQsMCwxLDEsMzQsMzRBMzQsMzQsMCwwLDEsOTQsMTIwWm05OS41MSw2OS42NGE3Ny41Myw3Ny41MywwLDAsMC00MC0zMS4zOCw0Niw0NiwwLDEsMC01MSwwLDc3LjUzLDc3LjUzLDAsMCwwLTQwLDMxLjM4LDkwLDkwLDAsMSwxLDEzMSwwWiIvPjwvc3ZnPg==');}.icon-password{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik00Niw1NlYyMDBhNiw2LDAsMCwxLTEyLDBWNTZhNiw2LDAsMCwxLDEyLDBabTk0LjU4LDU2LjQxTDExOCwxMTkuNzRWOTZhNiw2LDAsMCwwLTEyLDB2MjMuNzRsLTIyLjU4LTcuMzNhNiw2LDAsMSwwLTMuNzEsMTEuNDFsMjIuNTgsNy4zMy0xNCwxOS4yMWE2LDYsMCwxLDAsOS43LDcuMDZsMTQtMTkuMjEsMTQsMTkuMjFhNiw2LDAsMCwwLDkuNy03LjA2bC0xNC0xOS4yMSwyMi41OC03LjMzYTYsNiwwLDEsMC0zLjcxLTExLjQxWm0xMDMuNTYsMy44NWE2LDYsMCwwLDAtNy41Ni0zLjg1TDIxNCwxMTkuNzRWOTZhNiw2LDAsMCwwLTEyLDB2MjMuNzRsLTIyLjU4LTcuMzNhNiw2LDAsMSwwLTMuNzEsMTEuNDFsMjIuNTgsNy4zMy0xMy45NSwxOS4yMWE2LDYsMCwxLDAsOS43LDcuMDZsMTQtMTkuMjEsMTQsMTkuMjFhNiw2LDAsMCwwLDkuNy03LjA2bC0xMy45NS0xOS4yMSwyMi41OC03LjMzQTYsNiwwLDAsMCwyNDQuMTQsMTE2LjI2WiIvPjwvc3ZnPg==');} |
| | |
| | | :target{outline:0!important;padding:0!important}.dashboard .qtoggle{left:0;bottom:0}.dashboard>header{justify-content:flex-end}.dashboard>header img{width:var(--btn)}.dashboard h1:first-of-type{margin-top:4rem!important}nav.dashboard-nav,nav.dashboard-nav ul{--dir:row}nav.dashboard-nav ul{touch-action:pan-x;overflow:auto hidden}main>footer{padding:0}main>*{max-width:min(768px,90vw)!important;margin:0 auto!important}main h1{margin:0!important;font-size:var(--txt-large)}.item-grid .item{position:relative}img{width:100%;height:auto;aspect-ratio:1;object-fit:cover}.replace.replace{grid-column:full;padding:0 var(--btn_);max-width:none!important;margin:0!important}.replace .dashboard-page{max-width:var(--wide)}.group-display .item-grid{grid-template-columns:repeat(2,1fr)}.item-grid{margin-bottom:4rem}.item-grid:has(.select-item:checked) .item{padding:.75rem;opacity:.8;filter:var(--filter)}.item-grid .item:has(.select-item:checked){padding:.5rem;filter:none;opacity:1;background-color:var(--action-0)}.grid-view .item>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.grid-view .item>input[type=checkbox]+label::before{transform:unset;top:.5rem;left:.5rem}.grid-view .item>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.grid-view .item .item-actions{bottom:0;right:0}.item-actions button{min-height:0;width:var(--chipchip);height:var(--chipchip);background-color:rgba(var(--base-rgb),var(--op-45))}.item-actions button:hover{background-color:var(--base)}.list-view h3,.list-view p{margin:0!important}.list-view h3{font-size:var(--txt-medium)}@media (min-width:768px){.grid-view{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}}@media (max-width:768px){.bulk-controls.bulk-controls.nowrap{--wrap:wrap}}.bulk-controls{margin:1rem 0}.bulk-controls .selected-count{font-weight:400;font-size:var(--txt-small);text-transform:none;font-style:italic;display:flex;gap:.25rem;margin-left:2rem}.selected-count::before{content:'{'}.selected-count::after{content:'}'}.bulk-edit-form .selected{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:4px}.selected label{padding:.5rem;opacity:.6;filter:var(--filter);border:2px solid transparent;transition:filter var(--trans-base),opacity var(--trans-base),border var(--trans-base),padding var(--trans-base)}.selected label:has(:checked){border-color:var(--action-0);padding:0;opacity:1;filter:none;transition:filter var(--trans-base),opacity var(--trans-base),border var(--trans-base),padding var(--trans-base)}form.table img,form.table label.select-item{width:6rem;height:6rem}form.table .item-grid.preview{margin:0}td p{width:max-content}.timeline-point.is-dragging{opacity:.4;position:relative}.timeline-point.drop-above{position:relative}.timeline-point.drop-above::before{content:'';position:absolute;top:-4px;left:0;right:0;height:8px;background:var(--action-0);border-radius:4px;z-index:10;animation:pulse .6s ease-in-out infinite}.timeline-point.drop-below{position:relative}.timeline-point.drop-below::after{content:'';position:absolute;bottom:-4px;left:0;right:0;height:8px;background:var(--action-0);border-radius:4px;z-index:10;animation:pulse .6s ease-in-out infinite}@keyframes pulse{0%,100%{opacity:.6;transform:scaleY(1)}50%{opacity:1;transform:scaleY(1.2)}}.timeline-point.drop-above{margin-top:8px;transition:margin-top .2s ease}.timeline-point.drop-below{margin-bottom:8px;transition:margin-bottom .2s ease}.drag-handle{cursor:grab;padding:.5rem;background:0 0;border:none;opacity:.6;transition:opacity .2s ease}.drag-handle:hover{opacity:1}.drag-handle:active,.is-dragging .drag-handle{cursor:grabbing}.drag-preview .drag-handle{pointer-events:none}.all-filters{margin:0;padding:1rem 0;border-top:1px solid var(--base-200);border-bottom:1px solid var(--base-200);--gap:0}.all-filters .row{--justify:flex-start}.all-filters[open]{--gap:.5rem}.all-filters summary{width:100%;display:flex;justify-content:space-between}.all-filters summary [data-action=clear-filters]{--w:1em!important;width:max-content;font-size:var(--txt-x-small)}.all-filters [data-action=refresh]{margin-left:auto;--w:1em!important;flex-wrap:nowrap;justify-content:flex-start;transition:var(--trans-size);display:flex;font-size:var(--txt-x-small)}.all-filters [data-action=refresh]:focus,.all-filters [data-action=refresh]:hover{width:max-content}.all-filters [data-action=refresh] span{display:none;white-space:nowrap}.all-filters [data-action=refresh]:focus span,.all-filters [data-action=refresh]:hover span{display:block}.all-filters .btn+label{box-shadow:var(--shdw-none);color:var(--base-200)}.all-filters .radio-options input:not(.ch):checked+label{box-shadow:rgba(var(--base-rgb),var(--op-6)) var(--shdw-inset);color:var(--contrast-200);border-color:var(--contrast-200)}details.uploader+.items-list .all-filters{border-top:none}.all-filters .filters{width:100%}.controls .radio-options,.filters.row.start{--align:center;--justify:flex-start;--gap:.5rem}.all-filters span.label{text-transform:uppercase;font-size:var(--txt-small);font-weight:900;width:15vw;display:inline-flex;align-items:center;padding-right:2rem}@media (max-width:767px){.all-filters>.row{padding:.5rem 0}.all-filters span.label{padding-top:.5rem;width:100%;border-top:1px solid var(--base-200)}}.controls .icon{--w:1.4rem}.all-filters .btn+label,.all-filters button{height:var(--chip_);padding:.125rem!important;min-width:0;min-height:var(--chip_);width:var(--chip_)}.all-filters>.row{padding:.25rem 0}.all-filters .btn+label:focus,.all-filters .btn+label:hover,.all-filters button:focus,.all-filters button:hover{background-color:transparent;color:var(--action-0);border-color:var(--action-0)}.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%}.crud form.table td .label,.crud form.table td label:not(.select-item-label):not(.radio-option){display:none}form.table textarea{width:250px;padding:.5rem}.multi-select summary{--gap:2rem;padding-right:2.5rem}dialog.bulk-edit[open],dialog.create[open],dialog.edit[open]{height:85vh;top:5vh}.tab-content h2{display:none}.group-fields.hours .group-fields,.group-fields.hours .group-fields .field{display:flex;justify-content:space-between;align-items:center}.group-fields.hours .group-fields{padding:1rem .5rem;gap:1rem}.group-fields.hours .group-fields:nth-of-type(2n+1){background-color:var(--base)}.group-fields.hours .group-fields .field{margin:0}.group-fields.hours .true-false{flex:1}.group-fields.hours .time{position:relative}.group-fields.hours .time label{margin:0;font-size:var(--txt-small);position:absolute;top:-1rem;left:0;color:var(--contrast-200)}.today_hours{width:min(500px,90vw)}.today_hours .group-fields{width:100%;padding:0;display:flex;justify-content:center;gap:.5rem}@media (min-width:768px){.today_hours .group-fields{padding:2rem}}.today_hours .field{margin:0}.dash .true-false{margin:0}.dash [type=submit]{width:90%}.dashboard.dash h2{text-transform:none;font-size:var(--txt-large)}.dashboard.dash .replace>ul{display:flex;list-style:none;align-items:flex-start;justify-content:flex-start;flex-wrap:wrap;gap:.5rem}nav.tabs.tabs{bottom:0;left:0;right:var(--btn)}.dashboard.settings nav.tabs.tabs{--height:3.5rem;--x:var(--btn_);position:fixed;bottom:var(--btn);left:var(--x);right:var(--x);z-index:99;width:calc(100% - var(--x) - var(--x));background-color:var(--base)}.jvb-seo-admin nav.tabs.tabs{position:sticky;padding-bottom:0;bottom:unset;left:0;right:0;top:var(--btn)}.jvb-seo-admin nav.tabs button{border:none;margin:0 .125rem;background-color:var(--base-200);box-shadow:var(--shdw-none)}.jvb-seo-admin nav.tabs button.active{background-color:var(--base);color:var(--action-0)}nav.integrations,nav.integrations a,nav.integrations li,nav.integrations ul{height:auto}.replace{overflow:hidden}body.dash form#options{display:flex;flex-flow:column nowrap;justify-content:center;align-items:center}.item-grid.integrations{grid-template-columns:repeat(2,1fr);gap:2rem}.integration{background:var(--base);border:2px solid var(--base-200);border-radius:var(--radius-outer);padding:1rem;position:relative;transition:all var(--trans-base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.integration.connected{border-color:var(--success)}.integration.disconnected,.integration.error{border-color:var(--error)}.integration.hasChanges{border-color:var(--warning)}.integration .header{margin-bottom:.75rem;padding-bottom:.75rem;border-bottom:2px solid var(--base-200)}.integration h3{letter-spacing:1px;font-size:var(--txt-medium);margin:0}.integration .meta{margin-bottom:1rem;text-align:right;color:var(--contrast-200);font-size:var(--txt-small)}.integration .setup{font-size:var(--txt-small);font-weight:700;text-transform:uppercase}.integration .setup .indicator{font-size:var(--txt-medium)}.integration .connected .indicator,.integration .setup .connected{color:var(--success)}.integration .disconnected .indicator,.integration .setup .disconnected{color:var(--error)}.integration.hasChanges .disconnected{color:var(--warning)}.connection-status.connected{background-color:var(--successBack);color:var(--successText)}.connection-status.disconnected{background-color:var(--errorBack);color:var(--errorText)}.integration code{display:inline-block;width:90%;margin:0 .5rem;user-select:all;padding:.75rem;border:2px solid var(--base);background-color:var(--base-200);word-break:break-all}.integration details+details{margin-top:1rem}.integration .actions{margin-top:1rem}.hasChanges button[data-action=save_credentials]{border-color:var(--warning);animation:pulse-color 1s infinite;animation-delay:1s}.flash{animation:flash .5s}.flash.connected{--b:var(--success)}.flash.disconnected{--b:var(--error)}.flash.syncing{--b:var(--success)}.flash.error,.flash.hasChanges{--b:var(--warning)}@keyframes flash{0%,100%{border-color:inherit}50%{border-color:var(--b)}}.location.field{width:80vw}.location.field>p{text-align:center}.location.field>p+p{margin:0 .5rem 0 0}.location.field .location-map{height:20vh}.location.field .location-links{padding:.5rem 0;display:flex;justify-content:space-evenly}.field.upload [data-upload-id],.item-grid .item{touch-action:none}.empty-state{grid-column:1/-1;padding:1rem 10vw;margin:0 10vw;border-radius:var(--radius-outer);background-color:var(--base-100)}.jvb-oauth-connect{position:relative;transition:opacity .2s}.jvb-oauth-connect.loading{opacity:.6;pointer-events:none}.jvb-oauth-connect.loading::after{content:'';position:absolute;right:-30px;top:50%;transform:translateY(-50%);width:16px;height:16px;border:2px solid #ccc;border-top-color:#0073aa;border-radius:50%;animation:oauth-spin .8s linear infinite}@keyframes oauth-spin{to{transform:translateY(-50%) rotate(360deg)}}.integration-status-message{padding:12px 16px;margin:16px 0;border-radius:4px;display:none;font-size:14px;line-height:1.5}.integration-status-message.success{display:block;background:#d4edda;color:#155724;border-left:4px solid #28a745}.integration-status-message.error{display:block;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545}.integration-status-message.info{display:block;background:#d1ecf1;color:#0c5460;border-left:4px solid #17a2b8}.connection-status{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:4px;font-size:13px;font-weight:500}.connection-status.connected{background:#d4edda;color:#155724}.connection-status.disconnected{background:#f8d7da;color:#721c24}.status-indicator{font-size:10px;line-height:1}.connection-status.connected .status-indicator{color:#28a745}.connection-status.disconnected .status-indicator{color:#dc3545}.referral-dashboard{max-width:var(--wide)}.card{background-color:var(--base-100);padding:30px;border-radius:var(--radius-outer);text-align:center;margin-bottom:2rem}.dashboard-page.referral{text-align:center}.referral-dashboard .empty-state{padding:3rem 7vw}.referral-dashboard .empty-state h3{margin-top:0}.referral-dashboard .empty-state h3 .icon:first-of-type{margin-right:1rem}.referral-dashboard .empty-state h3 .icon:last-of-type{margin-left:1rem}.item-grid.stats .card{border:1px solid var(--base);display:flex;justify-content:flex-end;align-items:center;flex-direction:column}.item-grid.stats .card.highlight{box-shadow:var(--contrast-rgb) var(--shadow);background-color:var(--action-200);color:var(--action-contrast);grid-column:1/-1;margin:0 4rem 30px;aspect-ratio:unset}.card h4{font-size:var(--medium);color:var(--contrast-200);font-weight:var(--fw-b-bold);margin:0 0 .5rem}.card span{color:var(--action-0);font-weight:var(--fw-b-bold);font-size:var(--txt-xx-large)}.card.highlight span{color:var(--action-contrast)}nav.sidebar{--wrap:nowrap;position:fixed;top:var(--btn);bottom:0;left:0;z-index:var(--z-4);height:calc(100% - var(--btn));background-color:var(--base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);width:var(--btn);transition:var(--trans-size);overflow:hidden auto}nav.sidebar .icon{--w:var(--chip_);width:var(--btn);transition:var(--trans-size),margin var(--trans-base)}nav.sidebar.open{width:fit-content;max-width:100%}nav.sidebar.open .icon{--w:var(--chip);margin:.75rem;width:var(--w)}nav.sidebar ul{height:max-content;width:100%;--gap:0}nav.sidebar .title{display:block}nav.sidebar .toggle{width:var(--btn);height:var(--chipchip);box-shadow:none;background-color:transparent;min-height:0}nav.sidebar .toggle:focus,nav.sidebar .toggle:hover{background-color:var(--action-0);color:var(--action-contrast)}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-rgb),var(--op-45)) var(--shdw)}nav.sidebar .title{white-space:nowrap}nav.sidebar li{--justify:center;flex-wrap:nowrap;overflow:hidden;align-items:flex-start}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 .a{color:var(--contrast-200)}nav.sidebar .a,nav.sidebar a{height:var(--chipchip);display:flex;justify-content:center;align-items:center;transition:none;padding-left:0}nav.sidebar.open .a,nav.sidebar.open a{width:100%;justify-content:flex-start}nav.sidebar .has-submenu ul{max-height:0;height:0;overflow:hidden;transition:var(--trans-size)}nav.sidebar .has-submenu.open>ul{height:100%;max-height:fit-content}header .title,header .title a{height:var(--btn);margin:0;display:block}header .title{margin-left:var(--btn)}header .title a{width:var(--btn)} |
| | | :target{outline:0!important;padding:0!important}.dashboard .qtoggle{left:0;bottom:0}.dashboard>header{justify-content:flex-end}.dashboard>header img{width:var(--btn)}.dashboard h1:first-of-type{margin-top:4rem!important}nav.dashboard-nav,nav.dashboard-nav ul{--dir:row}nav.dashboard-nav ul{touch-action:pan-x;overflow:auto hidden}main>footer{padding:0}main>*{max-width:min(768px,90vw)!important;margin:0 auto!important}main h1{margin:0!important;font-size:var(--txt-large)}.item-grid .item{position:relative}img{width:100%;height:auto;aspect-ratio:1;object-fit:cover}.replace.replace{grid-column:full;padding:0 var(--btn_);max-width:none!important;margin:0!important}.replace .dashboard-page{max-width:var(--wide)}.group-display .item-grid{grid-template-columns:repeat(2,1fr)}.item-grid{margin-bottom:4rem}.item-grid:has(.select-item:checked) .item{padding:.75rem;opacity:.8;filter:var(--filter)}.item-grid .item:has(.select-item:checked){padding:.5rem;filter:none;opacity:1;background-color:var(--action-0)}.grid-view .item>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.grid-view .item>input[type=checkbox]+label::before{transform:unset;top:.5rem;left:.5rem}.grid-view .item>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.grid-view .item .item-actions{bottom:0;right:0}.item-actions button{min-height:0;width:var(--chipchip);height:var(--chipchip);background-color:rgba(var(--base-rgb),var(--op-45))}.item-actions button:hover{background-color:var(--base)}.list-view h3,.list-view p{margin:0!important}.list-view h3{font-size:var(--txt-medium)}@media (min-width:768px){.grid-view{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}}@media (max-width:768px){.bulk-controls.bulk-controls.nowrap{--wrap:wrap}}.bulk-controls{margin:1rem 0}.bulk-controls .selected-count{font-weight:400;font-size:var(--txt-small);text-transform:none;font-style:italic;display:flex;gap:.25rem;margin-left:2rem}.selected-count::before{content:'{'}.selected-count::after{content:'}'}.bulk-edit-form .selected{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:4px}.selected label{padding:.5rem;opacity:.6;filter:var(--filter);border:2px solid transparent;transition:filter var(--trans-base),opacity var(--trans-base),border var(--trans-base),padding var(--trans-base)}.selected label:has(:checked){border-color:var(--action-0);padding:0;opacity:1;filter:none;transition:filter var(--trans-base),opacity var(--trans-base),border var(--trans-base),padding var(--trans-base)}form.table img,form.table label.select-item{width:6rem;height:6rem}form.table .item-grid.preview{margin:0}td p{width:max-content}.timeline-point.is-dragging{opacity:.4;position:relative}.timeline-point.drop-above{position:relative}.timeline-point.drop-above::before{content:'';position:absolute;top:-4px;left:0;right:0;height:8px;background:var(--action-0);border-radius:4px;z-index:10;animation:pulse .6s ease-in-out infinite}.timeline-point.drop-below{position:relative}.timeline-point.drop-below::after{content:'';position:absolute;bottom:-4px;left:0;right:0;height:8px;background:var(--action-0);border-radius:4px;z-index:10;animation:pulse .6s ease-in-out infinite}@keyframes pulse{0%,100%{opacity:.6;transform:scaleY(1)}50%{opacity:1;transform:scaleY(1.2)}}.timeline-point.drop-above{margin-top:8px;transition:margin-top .2s ease}.timeline-point.drop-below{margin-bottom:8px;transition:margin-bottom .2s ease}.drag-handle{cursor:grab;padding:.5rem;background:0 0;border:none;opacity:.6;transition:opacity .2s ease}.drag-handle:hover{opacity:1}.drag-handle:active,.is-dragging .drag-handle{cursor:grabbing}.drag-preview .drag-handle{pointer-events:none}.all-filters{margin:0;padding:1rem 0;border-top:1px solid var(--base-200);border-bottom:1px solid var(--base-200);--gap:0}.all-filters .row{--justify:flex-start}.all-filters[open]{--gap:.5rem}.all-filters summary{width:100%;display:flex;justify-content:space-between}.all-filters summary [data-action=clear-filters]{--w:1em!important;width:max-content;font-size:var(--txt-x-small)}.all-filters [data-action=refresh]{margin-left:auto;--w:1em!important;flex-wrap:nowrap;justify-content:flex-start;transition:var(--trans-size);display:flex;font-size:var(--txt-x-small)}.all-filters [data-action=refresh]:focus,.all-filters [data-action=refresh]:hover{width:max-content}.all-filters [data-action=refresh] span{display:none;white-space:nowrap}.all-filters [data-action=refresh]:focus span,.all-filters [data-action=refresh]:hover span{display:block}.all-filters .btn+label{box-shadow:var(--shdw-none);color:var(--base-200)}.all-filters .radio-options input:not(.ch):checked+label{box-shadow:rgba(var(--base-rgb),var(--op-6)) var(--shdw-inset);color:var(--contrast-200);border-color:var(--contrast-200)}details.uploader+.items-list .all-filters{border-top:none}.all-filters .filters{width:100%}.controls .radio-options,.filters.row.start{--align:center;--justify:flex-start;--gap:.5rem}.all-filters span.label{text-transform:uppercase;font-size:var(--txt-small);font-weight:900;width:15vw;display:inline-flex;align-items:center;padding-right:2rem}@media (max-width:767px){.all-filters>.row{padding:.5rem 0}.all-filters span.label{padding-top:.5rem;width:100%;border-top:1px solid var(--base-200)}}.controls .icon{--w:1.4rem}.all-filters .btn+label,.all-filters button{height:var(--chip_);padding:.125rem!important;min-width:0;min-height:var(--chip_);width:var(--chip_)}.all-filters>.row{padding:.25rem 0}.all-filters .btn+label:focus,.all-filters .btn+label:hover,.all-filters button:focus,.all-filters button:hover{background-color:transparent;color:var(--action-0);border-color:var(--action-0)}.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%}.crud form.table td .label,.crud form.table td label:not(.select-item-label):not(.radio-option){display:none}form.table textarea{width:250px;padding:.5rem}.multi-select summary{--gap:2rem;padding-right:2.5rem}dialog.bulk-edit[open],dialog.create[open],dialog.edit[open]{height:85vh;top:5vh}.tab-content h2{display:none}.group-fields.hours .group-fields,.group-fields.hours .group-fields .field{display:flex;justify-content:space-between;align-items:center}.group-fields.hours .group-fields{padding:1rem .5rem;gap:1rem}.group-fields.hours .group-fields:nth-of-type(2n+1){background-color:var(--base)}.group-fields.hours .group-fields .field{margin:0}.group-fields.hours .true-false{flex:1}.group-fields.hours .time{position:relative}.group-fields.hours .time label{margin:0;font-size:var(--txt-small);position:absolute;top:-1rem;left:0;color:var(--contrast-200)}.today_hours{width:min(500px,90vw)}.today_hours .group-fields{width:100%;padding:0;display:flex;justify-content:center;gap:.5rem}@media (min-width:768px){.today_hours .group-fields{padding:2rem}}.today_hours .field{margin:0}.dash .true-false{margin:0}.dash [type=submit]{width:90%}.dashboard.dash h2{text-transform:none;font-size:var(--txt-large)}.dashboard.dash .replace>ul{display:flex;list-style:none;align-items:flex-start;justify-content:flex-start;flex-wrap:wrap;gap:.5rem}nav.tabs.tabs{bottom:0;left:0;right:var(--btn)}.dashboard.settings nav.tabs.tabs{--height:3.5rem;--x:var(--btn_);position:fixed;bottom:var(--btn);left:var(--x);right:var(--x);z-index:99;width:calc(100% - var(--x) - var(--x));background-color:var(--base)}.jvb-seo-admin nav.tabs.tabs{position:sticky;padding-bottom:0;bottom:unset;left:0;right:0;top:var(--btn)}.jvb-seo-admin nav.tabs button{border:none;margin:0 .125rem;background-color:var(--base-200);box-shadow:var(--shdw-none)}.jvb-seo-admin nav.tabs button.active{background-color:var(--base);color:var(--action-0)}nav.integrations,nav.integrations a,nav.integrations li,nav.integrations ul{height:auto}.replace{overflow:hidden}body.dash form#options{display:flex;flex-flow:column nowrap;justify-content:center;align-items:center}.item-grid.integrations{grid-template-columns:repeat(2,1fr);gap:2rem}.integration{background:var(--base);border:2px solid var(--base-200);border-radius:var(--radius-outer);padding:1rem;position:relative;transition:all var(--trans-base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.integration.connected{border-color:var(--success)}.integration.disconnected,.integration.error{border-color:var(--error)}.integration.hasChanges{border-color:var(--warning)}.integration .header{margin-bottom:.75rem;padding-bottom:.75rem;border-bottom:2px solid var(--base-200)}.integration h3{letter-spacing:1px;font-size:var(--txt-medium);margin:0}.integration .meta{margin-bottom:1rem;text-align:right;color:var(--contrast-200);font-size:var(--txt-small)}.integration .setup{font-size:var(--txt-small);font-weight:700;text-transform:uppercase}.integration .setup .indicator{font-size:var(--txt-medium)}.integration .connected .indicator,.integration .setup .connected{color:var(--success)}.integration .disconnected .indicator,.integration .setup .disconnected{color:var(--error)}.integration.hasChanges .disconnected{color:var(--warning)}.connection-status.connected{background-color:var(--successBack);color:var(--successText)}.connection-status.disconnected{background-color:var(--errorBack);color:var(--errorText)}.integration code{display:inline-block;width:90%;margin:0 .5rem;user-select:all;padding:.75rem;border:2px solid var(--base);background-color:var(--base-200);word-break:break-all}.integration details+details{margin-top:1rem}.integration .actions{margin-top:1rem}.hasChanges button[data-action=save_credentials]{border-color:var(--warning);animation:pulse-color 1s infinite;animation-delay:1s}.flash{animation:flash .5s}.flash.connected{--b:var(--success)}.flash.disconnected{--b:var(--error)}.flash.syncing{--b:var(--success)}.flash.error,.flash.hasChanges{--b:var(--warning)}@keyframes flash{0%,100%{border-color:inherit}50%{border-color:var(--b)}}.location.field{width:80vw}.location.field>p{text-align:center}.location.field>p+p{margin:0 .5rem 0 0}.location.field .location-map{height:20vh}.location.field .location-links{padding:.5rem 0;display:flex;justify-content:space-evenly}.field.upload [data-upload-id],.item-grid .item{touch-action:none}.empty-state{grid-column:1/-1;padding:1rem 10vw;margin:0 10vw;border-radius:var(--radius-outer);background-color:var(--base-100)}.jvb-oauth-connect{position:relative;transition:opacity .2s}.jvb-oauth-connect.loading{opacity:.6;pointer-events:none}.jvb-oauth-connect.loading::after{content:'';position:absolute;right:-30px;top:50%;transform:translateY(-50%);width:16px;height:16px;border:2px solid #ccc;border-top-color:#0073aa;border-radius:50%;animation:oauth-spin .8s linear infinite}@keyframes oauth-spin{to{transform:translateY(-50%) rotate(360deg)}}.integration-status-message{padding:12px 16px;margin:16px 0;border-radius:4px;display:none;font-size:14px;line-height:1.5}.integration-status-message.success{display:block;background:#d4edda;color:#155724;border-left:4px solid #28a745}.integration-status-message.error{display:block;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545}.integration-status-message.info{display:block;background:#d1ecf1;color:#0c5460;border-left:4px solid #17a2b8}.connection-status{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:4px;font-size:13px;font-weight:500}.connection-status.connected{background:#d4edda;color:#155724}.connection-status.disconnected{background:#f8d7da;color:#721c24}.status-indicator{font-size:10px;line-height:1}.connection-status.connected .status-indicator{color:#28a745}.connection-status.disconnected .status-indicator{color:#dc3545}.referral-dashboard{max-width:var(--wide)}.card{background-color:var(--base-100);padding:30px;border-radius:var(--radius-outer);text-align:center;margin-bottom:2rem}.dashboard-page.referral{text-align:center}.referral-dashboard .empty-state{padding:3rem 7vw}.referral-dashboard .empty-state h3{margin-top:0}.referral-dashboard .empty-state h3 .icon:first-of-type{margin-right:1rem}.referral-dashboard .empty-state h3 .icon:last-of-type{margin-left:1rem}.item-grid.stats .card{border:1px solid var(--base);display:flex;justify-content:flex-end;align-items:center;flex-direction:column}.item-grid.stats .card.highlight{box-shadow:var(--contrast-rgb) var(--shadow);background-color:var(--action-200);color:var(--action-contrast);grid-column:1/-1;margin:0 4rem 30px;aspect-ratio:unset}.card h4{font-size:var(--medium);color:var(--contrast-200);font-weight:var(--fw-b-bold);margin:0 0 .5rem}.card span{color:var(--action-0);font-weight:var(--fw-b-bold);font-size:var(--txt-xx-large)}.card.highlight span{color:var(--action-contrast)}nav.sidebar{--wrap:nowrap;position:fixed;top:var(--btn);bottom:0;left:0;z-index:var(--z-4);height:calc(100% - var(--btn));background-color:var(--base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);width:var(--btn);transition:var(--trans-size);overflow:hidden auto}nav.sidebar .icon{--w:var(--chip_);width:var(--btn);transition:var(--trans-size),margin var(--trans-base)}nav.sidebar.open{width:fit-content;max-width:100%}nav.sidebar.open .icon{--w:var(--chip);margin:.75rem;width:var(--w)}nav.sidebar ul{height:max-content;width:100%;--gap:0}nav.sidebar .title{display:block}nav.sidebar .toggle{width:var(--btn);height:var(--chipchip);box-shadow:none;background-color:transparent;min-height:0}nav.sidebar .toggle:focus,nav.sidebar .toggle:hover{background-color:var(--action-0);color:var(--action-contrast)}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-rgb),var(--op-45)) var(--shdw)}nav.sidebar .title{white-space:nowrap}nav.sidebar li{--justify:center;flex-wrap:nowrap;overflow:hidden;align-items:flex-start}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 .a{color:var(--contrast-200)}nav.sidebar .a,nav.sidebar a{height:var(--chipchip);display:flex;justify-content:center;align-items:center;transition:none;padding-left:0}nav.sidebar.open .a,nav.sidebar.open a{width:100%;justify-content:flex-start}nav.sidebar .has-submenu ul{max-height:0;height:0;overflow:hidden;transition:var(--trans-size)}nav.sidebar .has-submenu.open>ul{height:100%;max-height:fit-content}header .title,header .title a{height:var(--btn);margin:0;display:block}header .title{margin-left:var(--btn)}header .title a{width:var(--btn)}.dashboard #queue{bottom:0} |
| | |
| | | input:is([type=date],[type=number],[type=text],[type=url],[type=email],[type=tel],[type=password],[type=search],[type=datetime-local],[type=time]),textarea{font-family:var(--body);font-size:var(--txt-medium);color:var(--contrast);padding:var(--p-y) var(--p-x);border-radius:var(--radius);background-color:var(--base);outline:0;border:1px solid var(--base-100);border-bottom:2px solid var(--contrast-200);width:100%;max-width:100%;margin:0 4px}input:is([type=date],[type=number],[type=text],[type=url],[type=email],[type=tel],[type=password],[type=search],[type=datetime-local],[type=time]):focus,textarea:focus{outline:var(--action-50);background-color:var(--base-100);color:var(--contrast)}input::placeholder,textarea::placeholder{font-family:var(--body);color:var(--base-200)}@media (min-width:768px){:root{--p-y:1rem}}select{background:var(--base);border:2px solid var(--base-100);border-radius:var(--radius);color:var(--contrast);cursor:pointer;font-family:var(--body);font-size:var(--txt-small);padding:.5rem 1rem;width:100%}select:disabled{background-color:var(--base-50);border-color:var(--base-100);color:var(--base-200);cursor:not-allowed}select option{background:var(--base);color:var(--contrast);padding:.5rem}select option:active,select option:checked,select option:focus,select option:hover{background:var(--action-0);color:var(--base);box-shadow:0 0 0 100px var(--action-0) inset}select option:checked{background:var(--action-0) linear-gradient(0deg,var(--action-0) 0,var(--action-0) 100%);color:var(--base)}select:hover{border-color:var(--action-0)}select:focus{border-color:var(--action-0)}input[type=search]:focus+.clear-search{opacity:1;cursor:pointer}.search-container .clear-search{opacity:0;cursor:default}.search-container .icon.search{padding:4px 8px;color:var(--contrast-200);--w:3rem}input[type=search]::-moz-search-clear-button,input[type=search]::-ms-clear,input[type=search]::-ms-reveal,input[type=search]::search-cancel-button{-webkit-appearance:none;-moz-appearance:none;appearance:none;display:none;visibility:hidden}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration,input[type=search]::-webkit-search-results-button,input[type=search]::-webkit-search-results-decoration{-webkit-appearance:none}input[type=url]{background:var(--linkIcon);background-position:.5em;background-size:1em;background-repeat:no-repeat;padding-left:2em}.integration .label,label{text-transform:uppercase;font-weight:700;margin-bottom:.5rem;display:block}.field{margin:2rem 0;position:relative}.field:has(.has-tooltip) label{margin-left:2rem}legend{padding:0 1rem}.date-wrapper{position:relative;display:inline-block}input[type=date]{padding:8px 36px 8px 8px;border-radius:4px}input[type=date]::-webkit-calendar-picker-indicator{opacity:0;width:100%;height:100%;position:absolute;top:0;left:0;cursor:pointer}input[type=date]+.icon{--w:20px;position:absolute;right:10px;top:50%;transform:translateY(-50%);pointer-events:none}input:is([type=time],[type=datetime-local],[type=date]){padding:.5rem;border:1px solid var(--contrast-200);border-radius:4px;font-size:14px;min-width:180px;background:var(--base);color:var(--contrast);cursor:pointer}.date-wrapper input[type=date]:focus,.datetime-wrapper input[type=datetime-local]:focus,.field-input-wrapper input:is([type=time],[type=datetime-local],[type=date]):focus,.time-wrapper input[type=time]:focus{border-color:var(--action-0);box-shadow:0 0 0 2px rgba(var(--action-rgb),.1)}.date-wrapper .icon,.datetime-wrapper .icon,.field-input-wrapper .icon,.time-wrapper .icon{width:18px;height:18px;background-color:var(--contrast);opacity:.7}[type=checkbox],[type=radio],input.ch{position:absolute;opacity:0;left:-200vw}[type=checkbox]+label,[type=radio]+label,input.ch+label{position:relative;cursor:pointer}[type=checkbox]+label:hover,[type=radio]+label:hover{color:var(--action-0)}[type=checkbox]+label::after,[type=checkbox]+label::before,[type=radio]+label::after,[type=radio]+label::before,input.ch+label::after,input.ch+label::before{content:'';position:absolute;top:50%}[type=checkbox]+label::after,[type=radio]+label::after,input.ch+label::after{left:5px;transform:translateY(-70%) rotate(45deg);width:5px;height:10px;border:solid var(--light-0);border-width:0 2px 2px 0;display:none}[type=checkbox]+label::before,[type=radio]+label::before,input.ch+label::before{left:0;transform:translateY(-50%);width:1rem;height:1rem;border:2px solid var(--contrast-200);background-color:var(--base);border-radius:var(--radius)}[type=checkbox]:hover+label::before,[type=radio]:hover+label::before,input.ch:hover+label::before{border-color:var(--action-200)}[type=checkbox]:checked+label::before,[type=radio]:checked+label::before,input.ch:checked+label::before{background-color:var(--action-0);border-color:var(--action-100)}[type=radio]:checked+label::before{border-radius:50%}[type=checkbox]:checked+label::after,input.ch:checked+label::after{display:block;left:5px;top:50%;transform:translateY(-70%) rotate(45deg);width:.35rem;height:.66rem;border:solid var(--light-0);border-width:0 2px 2px 0}[type=checkbox]:disabled+label,[type=radio]:disabled+label,input.ch:disabled+label{cursor:not-allowed;background-color:var(--base-50);color:var(--base-200);border-color:var(--base-200)}[type=checkbox]:disabled+label:hover,[type=radio]:disabled+label:hover,input.ch:disabled+label:hover{background-color:var(--base-50);color:var(--base-200);border-color:var(--base-200)}[type=checkbox]:disabled+label::before,[type=radio]:disabled+label::before,input.ch:disabled+label::before{border-color:var(--base-200)}[type=checkbox]:not(.btn)+label,[type=radio]:not(.btn)+label,input.ch+label{flex:1;padding-left:2rem;transform-origin:top center;will-change:transform}.btn+label::after,.btn+label::before{display:none}.btn+label{--w:1.2em;border:1px solid var(--base-200);border-radius:var(--radius);min-width:2rem;min-height:2rem;margin:0;display:flex;justify-content:center;align-items:center;flex-wrap:nowrap;gap:.5rem;color:var(--contrast-200);opacity:.8}.radio-options.status label{padding:0 .5rem}.btn:checked+label{border-color:var(--contrast);color:var(--contrast);opacity:1}.btn+label:hover{color:var(--action-50);border-color:var(--action-50)}.btn[hidden]+label,input[hidden]+label{display:none!important}.checkbox-options{--gap:.5rem 2rem}.checkbox-options label{flex:unset!important}.radio-options{--gap:.125rem .5rem}.radio-options input:not(.ch)+label::before{display:none!important}.radio-options input:not(.ch)+label{flex:unset!important;padding:.25rem!important;border-radius:4px;border:1px solid var(--base-100);color:var(--contrast-200);font-weight:400;text-align:center}.radio-options input:not(.ch)+label:hover,.radio-options input:not(.ch):checked+label{border-color:var(--action-0);color:var(--action-0)}.quantity{margin:0;display:inline-flex;width:fit-content;align-items:center;justify-content:center;border:1px solid transparent;border-radius:4px;position:relative}.quantity:focus-within{border-color:var(--action-0)}.quantity label{margin:0;font-size:var(--txt-small)}.quantity button{background:var(--base);padding:0;width:var(--chip_);height:var(--chip_);min-height:0;z-index:0;position:relative;border:1px solid var(--base-200);color:var(--contrast-200)}.quantity button:hover:not(:disabled){color:var(--action-0);border-color:var(--action-0);background-color:var(--base)}.quantity button:active:not(:disabled){background-color:var(--action-0);color:var(--light-0);transform:scale(.95)}.quantity button:disabled{opacity:.5;cursor:not-allowed}.quantity input[type=number]{z-index:1;border:1px solid var(--base-200);background:var(--base);text-align:center;font-size:1.1rem;width:60px;height:48px;margin:0;padding:0!important;appearance:textfield}.quantity input[type=number]::-webkit-inner-spin-button,.quantity input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.quantity input[type=number]:focus{background-color:var(--base-50)}.quantity button.increase{left:-2px;border-radius:0 4px 4px 0}.quantity button.decrease{right:-2px;border-radius:4px 0 0 4px}.tab-content[hidden]{display:block!important;transform:scaleY(0);height:0;overflow:hidden}.tab-content[hidden]:focus-within{transform:scaleY(1);height:auto}nav.tabs h2{margin:0!important;line-height:1;font-size:var(--txt-medium);display:flex;color:var(--contrast);white-space:nowrap;gap:1rem}nav.tabs .active h2{color:var(--action-contrast)}nav.tabs button{padding:.75rem 1.5rem;border-radius:0;position:relative;border:2px solid var(--action-0)}nav.tabs>button:first-of-type{border-top-left-radius:var(--radius)}nav.tabs>button:last-of-type{border-top-right-radius:var(--radius)}.tabs>button:focus,.tabs>button:hover{background-color:var(--base-200)}.tabs>button::after{content:'';position:absolute;bottom:-2px;left:0;width:0;height:3px;background-color:var(--action-50);transition:width .3s}.tabs>button.active::after,.tabs>button:hover::after{width:100%}.tabs>button.active::after{background-color:var(--action-200)}.tabs>button.active{background-color:var(--action-0);color:var(--action-contrast)}.tabs>button.active:focus,.tabs>button.active:hover{background-color:var(--action-100)}.tab-content h2{display:none}details.uploader .file-upload-container{margin:1rem 0;max-width:100%}@media (min-width:768px){details.uploader .file-upload-container{margin:1rem var(--mr) 1rem var(--ml);max-width:var(--content)}}.empty-group,.file-upload-wrapper{border:2px dashed var(--action-0);border-radius:4px;padding:2rem;text-align:center;transition:all .3s ease;background:rgba(var(--action-rgb),var(--op-1));position:relative;cursor:pointer}.file-upload-wrapper h2{margin:0!important;font-size:var(--txt-large)}.dragover,.empty-group,.file-upload-wrapper:hover{background:rgba(var(--action-rgb),var(--op-2));border-color:var(--action-0)!important}.file-upload-wrapper input[type=file]{position:absolute;left:0;top:0;width:100%;height:100%;opacity:0;cursor:pointer}.empty-group p,.file-upload-text{color:var(--contrast);margin:0}.empty-group p strong,.file-upload-text strong{color:var(--action-0);text-decoration:underline}.field.upload{position:relative}.field.upload:not(.uploading) .progress{display:none}.field.upload .actions{position:absolute;top:0;right:0}.item-grid.groups{grid-template-columns:repeat(1,1fr)}.item-grid.group{margin-bottom:0}.item-grid.group .item,.item-grid.preview .item,.item-grid.restore .item{display:block}.item-grid.group button,.item-grid.preview button,.item-grid.restore button{padding:.25rem .5rem}.item-grid.group button .icon,.item-grid.preview button .icon,.item-grid.restore button .icon{--w:1.1em}.item-grid.group .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.preview .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.restore .item .preview>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.item-grid.group .item .preview>input[type=checkbox]+label:before,.item-grid.preview .item .preview>input[type=checkbox]+label:before,.item-grid.restore .item .preview>input[type=checkbox]+label:before{transform:unset;top:.5rem;left:.5rem}.item-grid.group .item .preview>input[type=checkbox]+label::after,.item-grid.preview .item .preview>input[type=checkbox]+label::after,.item-grid.restore .item .preview>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.item-grid.group .item .item-actions,.item-grid.preview .item .item-actions,.item-grid.restore .item .item-actions{position:absolute;top:0;right:0;padding-left:var(--chipchip)}.item-grid.group summary,.item-grid.preview summary,.item-grid.restore summary{padding:.5rem}.item-grid.group:has([type=checkbox]:checked),.item-grid.preview:has([type=checkbox]:checked),.item-grid.restore:has([type=checkbox]:checked){padding:1rem;background-color:rgba(var(--contrast-rgb),var(--op-1))}.item-grid.group:has([type=checkbox]:checked) .item,.item-grid.preview:has([type=checkbox]:checked) .item,.item-grid.restore:has([type=checkbox]:checked) .item{padding:.75rem;opacity:.8}.item-grid.group:has([type=checkbox]:checked) .item img,.item-grid.preview:has([type=checkbox]:checked) .item img,.item-grid.restore:has([type=checkbox]:checked) .item img{filter:var(--filter)}.item-grid.group:has([type=checkbox]:checked) details,.item-grid.preview:has([type=checkbox]:checked) details,.item-grid.restore:has([type=checkbox]:checked) details{display:none}.item-grid.group .item:has([type=checkbox]:checked),.item-grid.preview .item:has([type=checkbox]:checked),.item-grid.restore .item:has([type=checkbox]:checked){padding:.5rem;background-color:rgba(var(--action-rgb),var(--op-4));opacity:1}.item-grid.preview summary span{display:none}.item-grid.group .item:has([type=checkbox]:checked) img,.item-grid.preview .item:has([type=checkbox]:checked) img,.item-grid.restore .item:has([type=checkbox]:checked) img{filter:none}[type=radio].featured+label .icon-star-fi,[type=radio].featured:checked+label .icon-star{display:none}[type=radio].featured+label .icon-star,[type=radio].featured:checked+label .icon-star-fi{display:inline-block}.restore.restore.item,.upload.upload.item{border-radius:var(--radius);aspect-ratio:unset;overflow:hidden;background:var(--base);border:1px solid var(--base-200)}.restore-item [for=select-item],.upload.item [for=select-item]{aspect-ratio:1}.upload.item:has(details[open]){grid-column:1/-1}.restore.item img,.upload.item img{transition:transform var(--trans-base)}.restore.item:hover img,.upload.item:hover img{transform:scale(1.02);transition:transform var(--trans-base)}.upload-group{background-image:var(--dashed-action);padding:5px;border-radius:var(--radius);background-color:rgba(var(--action-rgb),var(--op-1))}.upload-group .selected .field{margin:0}.upload-group .selection-actions button{aspect-ratio:unset}.submit-uploads{position:fixed;bottom:0;right:var(--btn_);z-index:var(--z-6);height:var(--btn);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);border-radius:var(--radius);animation:pulse-color 5s infinite;animation-delay:1s;background-color:var(--action-0);color:var(--action-contrast)}.submit-uploads:hover{background-color:var(--base-200);color:var(--contrast-200)}.empty-group{order:-1;grid-column:1/-1;padding:20px;background-image:var(--dashed-action);border-radius:var(--radius);margin:10px 0;cursor:pointer;transition:all var(--trans-base);text-align:center;background-color:rgba(var(--action-rgb),var(--op-1))}.group-display:not([hidden])~.file-upload-container{display:none}.dragging,.upload.item.dragging{opacity:.7;transform:scale(.95) rotate(3deg);z-index:var(--z-7);box-shadow:0 8px 25px rgba(0,0,0,.3)}.dragover{background:rgba(var(--action-rgb),var(--op-3))!important;border-color:var(--action-0)!important;transform:scale(1.05);animation:drop-pulse .8s infinite ease-in-out}.drag-preview{position:fixed;z-index:var(--z-9);width:fit-content;overflow:visible;pointer-events:none;opacity:.9;transform:scale(1.05);transition:transform .2s ease}.drag-preview .drag-items{width:max-content;height:max-content;position:relative}.drag-preview .drag-items .drag-item{width:120px;height:120px;position:absolute;top:0;left:0;background:var(--base);border-radius:var(--radius-outer);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.drag-preview .drag-items .drag-item:nth-child(1){transform:rotate(-3deg);z-index:3}.drag-preview .drag-items .drag-item:nth-child(2){left:8px;top:-4px;transform:rotate(4deg);z-index:2;transition-delay:30ms}.drag-preview .drag-items .drag-item:nth-child(3){left:-6px;top:-8px;transform:rotate(-5deg);z-index:1;transition-delay:60ms}.drag-preview .drag-items .drag-item:nth-child(4){left:12px;top:-12px;transform:rotate(3deg);z-index:0;transition-delay:90ms}.drag-preview .drag-items .drag-item:nth-child(n+5){left:-10px;top:-16px;transform:rotate(-4deg);z-index:0;opacity:.8}.drag-preview .drag-items img,.drag-preview .drag-items video{width:100%;height:100%;object-fit:cover;display:block}.drag-preview .drag-count{position:absolute;top:-8px;right:-8px;background:var(--base-200);color:var(--contrast);border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-3)}.item.dragging{opacity:.5;transform:scale(.95);filter:grayscale(50%);transition:opacity .2s ease,transform .2s ease,filter .2s ease}@keyframes drop-pulse{0%,100%{background-color:rgba(var(--action-rgb),var(--op-3));transform:scale(1.02)}50%{background-color:var(rgba(var(--action-rgb),var(--op-4)));transform:scale(1.04)}}.selection-actions{display:flex;gap:.25rem}@media (max-width:767px){body:not(.uploading):has(.group-display:not([hidden])){overflow:hidden}body:not(.uploading):has(.group-display:not([hidden])) .qtoggle{z-index:var(--z-1)}.group-display.group-display{position:fixed;top:var(--btn);bottom:var(--btn);left:0;right:0;max-height:var(--maxHeight);overflow:hidden;z-index:var(--z-6);width:calc(100% - 1rem);height:calc(100% - 1rem);padding:0 0 3rem;--justify:flex-start;--align:flex-start;--gap:0}.group-display::before{content:'';display:block;z-index:-1;top:-.5rem;bottom:-.5rem;left:-.5rem;right:-.5rem;position:absolute;background-color:rgba(var(--base-rgb),var(--op-6));filter:blur(5px)}.group-display .preview-wrap,.group-display .sidebar{--wrap:nowrap;height:50%;overflow:hidden auto;position:relative;padding:.5rem}.group-display .preview-wrap{top:0}.group-display .preview-wrap .selected{display:flex;justify-content:space-between;align-items:center}.group-display .sidebar{bottom:0;flex-wrap:nowrap;overflow:hidden auto;background-color:var(--contrast-200);color:var(--base)}.group-display .sidebar>.hint{color:var(--contrast)}.group-display .sidebar .header{display:none}.group-display .preview-actions{top:0;flex-shrink:0}.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{bottom:0;margin:0;text-align:center}.group-display .preview-actions,.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{position:absolute;left:0;right:0;background-color:rgba(var(--base-rgb),var(--op-6));z-index:var(--z-3);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.group-display .item-grid{height:100%;overflow:hidden auto;grid-template-columns:repeat(3,1fr);padding:2rem 0}.group-display .sidebar>.item-grid{grid-template-columns:repeat(1,1fr);gap:1rem;padding:0}.group-display .sidebar .empty-group{order:0;position:sticky;height:fit-content;top:0;z-index:var(--z-3);background-color:rgba(var(--action-rgb),var(--op-6))}.group-display .sidebar .upload-group{order:1}.group-display .sidebar .empty-group p{margin:0}.group-display .field,.group-display .field label{margin:0;padding:0}.group-display .sidebar h4{margin:.25rem}.group-display .item{width:100%;height:max-content}.submit-uploads{bottom:var(--btn);left:0;right:0;width:100%;height:3rem}body.uploading .group-display.group-display{position:relative;top:unset;bottom:unset;right:unset;left:unset}}@media (min-width:768px){.group-display.group-display{--wrap:nowrap;--dir:row;--gap:1rem;--align:flex-start}.group-display .preview-wrap,.group-display .sidebar{--justify:flex-start;--wrap:nowrap;max-height:calc(100vh - var(--btnbtn));overflow:hidden auto}.group-display .preview-wrap,.group-display .sidebar{width:50%}.preview-actions,.preview-wrap .hint{position:sticky;z-index:var(--z-3);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);background-color:var(--base);width:100%}.preview-actions{top:0;left:0;right:0}.preview-actions .field{margin:0}.preview-wrap .hint,.sidebar>.hint{bottom:-1rem;padding-bottom:1rem;margin:0;left:0;right:0;text-align:center}}.item-grid.restore{grid-template-columns:repeat(1,1fr)}dialog nav.tabs{position:sticky;top:0;background-color:var(--base-50);z-index:var(--z-6);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw-down);margin-bottom:2rem}.editor-container .ql-toolbar{display:flex;background-color:var(--base-50);justify-content:flex-start;flex-wrap:wrap;padding:.25rem;gap:.5rem 1rem;border-top-left-radius:var(--radius);border-top-right-radius:var(--radius);border-bottom:4px solid var(--base-50)}.ql-toolbar .ql-formats{display:flex;gap:.25rem}.editor-container .ql-container{--padding:1rem;background-color:var(--base);border-bottom-left-radius:var(--radius);border-bottom-right-radius:var(--radius);height:fit-content;padding:2px;border:1px solid var(--base-200)}.editor-container .ql-container .ql-editor{padding:var(--padding);width:100%;height:100%}.ql-editor img{max-width:50%;height:auto}.ql-clipboard{left:-100000px;height:1px;overflow-y:hidden;position:absolute;top:50%}.ql-hidden{display:none}.ql-tooltip{position:absolute;transform:translateY(10px);background-color:var(--base-100);border:1px solid var(--base);box-shadow:0 0 5px rgba(var(--base-rgb),var(--op-6));color:var(--contrast);padding:5px 12px;white-space:nowrap}[data-type=single] .item-grid{display:flex}.repeater-row details summary::after{margin-left:0}.repeater-row details summary button{margin-left:auto}/*!* Group actions buttons - more visible *!*//*!* Group item grid - distinct from preview grid *!*//*!* Group count hint *!*//*!* ============================================================================*//*!* Base drag preview *!*//*!* Single item drag preview *!*//*!* Multi-item drag preview container *!*//*!* Items being dragged - reduce opacity on originals *!*//*!* Count badge on multi-item preview *!*//*!* ============================================================================*//*!* Ensure progress bar is visible when needed *!*//*!* Progress bar track *!*//*!* Progress bar fill *!*//*!* Progress details - styled for row layout with text and count *!*//*!* Individual item progress - overlay style *!*//*!* Item progress icon and status text *!*//*!* ============================================================================*//*!* Hide uploader when we have uploads *!*//*!* Show group display when we have uploads *!*//*!* ============================================================================*//*!* Selected items - more obvious *!*//*!* Selection checkbox - always visible on hover or when checked *!*//*!* Selection controls - more prominent *!*//*!* ============================================================================*//*!* Smooth dragover animation *!*//*!* ============================================================================*//*!* ============================================================================*//*!* Notification container - fixed overlay *!*//*!* Content card *!*//*!* Message section *!*//*!* Scrollable field list *!*//*!* Item grid for restore preview *!*//*!* Restore item *!*//*!* Checked state *!*//*!* Preview section *!*//*!* Item info *!*//*!* Checkbox controls *!*//*!* Actions section *!*//*!* Selection controls *!*//*!* Action buttons *!*//*!* Restore button - primary action *!*//*!* Scrap cache button - destructive action *!*//*!* Dismiss button - secondary action *!*//*!* Mobile responsive *!*//*!* Animation *!*//*!* Scrollbar styling for restore field list *!*/form{--step-size:2.5rem}.form-progress{padding:0 1rem}.form-progress .progress{background:var(--base-100);border-radius:var(--radius);padding:1rem}.form-progress .bar{height:6px;background:var(--base-200);border-radius:3px;overflow:hidden;margin-bottom:.5rem}.form-progress .fill{height:100%;background:linear-gradient(90deg,var(--action-0),var(--action-200));width:0%;transition:width .4s ease;border-radius:3px}.form-progress .step-text{font-size:var(--txt-small);font-weight:600;color:var(--contrast-200)}form nav.tabs{position:relative;top:0;left:0;right:0;padding:1rem 0;gap:0;z-index:0}form nav.tabs button{position:relative;background:0 0;border:none;padding:.5rem 1rem .5rem 3rem;z-index:1}form nav.tabs .step-number{width:2.5rem;height:100%;border-radius:50% 0 0 50%;position:absolute;left:0;top:0;background:var(--base-200);color:var(--contrast-50);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:var(--txt-small);border:3px solid var(--base)}form nav.tabs button.pending .step-number{background:var(--base-100);color:var(--contrast-200)}form nav.tabs button.active .step-number,form nav.tabs button.current .step-number{background:var(--action-0);color:var(--action-contrast);border-color:var(--action-200)}form nav.tabs button.completed .step-number{background:var(--successBack);color:var(--successBack);border-color:var(--successText)}form nav.tabs button.completed .step-number::before{content:'✓';font-size:1.2rem;color:var(--successText);position:absolute}form nav.tabs button.completed h2{color:var(--contrast-200)}.step-navigation{margin-top:2rem;padding-top:2rem;border-top:1px solid var(--base-200);gap:1rem}.step-navigation .prev-step{background:var(--base-100)}.step-navigation .next-step,.step-navigation button[type=submit]{margin-left:auto}.field input.error,.field select.error,.field textarea.error{border-color:var(--errorBack)}.error-message{color:var(--errorText);font-size:var(--txt-small);margin-top:.25rem;display:block}@media (max-width:768px){form nav.tabs button{min-width:80px;font-size:var(--txt-small)}form nav.tabs button h2{font-size:var(--txt-small)}form{--step-size:2rem}}.field-input-wrapper{position:relative;display:flex;align-items:center;gap:.5rem}.field-input-wrapper input,.field-input-wrapper select,.field-input-wrapper textarea{flex:1}.validation-icon{display:flex;align-items:center;justify-content:center;font-size:1.25rem;animation:scaleIn .3s ease;--w:1.25rem}.validation-icon.error{color:var(--error)}.validation-icon.success{color:var(--success)}@keyframes scaleIn{from{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}.validation-message{color:var(--error-0);font-size:var(--txt-small);margin-top:.25rem;display:block;animation:slideDown .2s ease}@keyframes slideDown{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.field.has-error input,.field.has-error select,.field.has-error textarea{border-color:var(--error);background-color:var(--errorBack)}.field.has-error input:focus,.field.has-error select:focus,.field.has-error textarea:focus{outline-color:var(--error);box-shadow:0 0 0 3px rgba(var(--error-rgb),.2)}.field.has-success input,.field.has-success select,.field.has-success textarea{border-color:var(--success)}.field label .required{color:var(--error);margin-left:.25rem}.form-summary{padding:2rem;border-radius:8px;margin-top:2rem;border:2px dashed var(--contrast-200)}.form-summary .message{margin-bottom:2rem}.form-summary .result+.result{position:relative;margin-top:1.5rem;padding-top:1.5rem}.form-summary .result+.result::before{position:absolute;top:0;left:16.5%;content:'';width:67%;height:1px;border-bottom:1px solid var(--base-200)}.form-summary h2{margin:1rem 0}.form-summary h4{background-color:var(--base-100);padding:.5rem 2rem;position:relative;left:-2rem;color:var(--contrast-200);font-size:.875rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem}.form-summary p{color:var(--text);margin:0}.group-summary,.repeater-summary{background:var(--base-100);padding:1rem;border-radius:4px;margin-top:.5rem}.repeater-row{margin-bottom:1rem}.repeater-row:last-child{margin-bottom:0}.ql-toolbar button{min-height:0;padding:.5rem}.success-message{color:var(--success);background-color:var(--successBack);border:1px solid var(--success);padding:.75rem 1rem;border-radius:var(--radius);margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.success-message .success-icon{width:1.25rem;height:1.25rem;flex-shrink:0}.success-box{background-color:var(--successBack);border:2px solid var(--success);padding:1.5rem;border-radius:var(--radius-outer);margin-bottom:1rem;text-align:center}.success-box h3{color:var(--success);margin-bottom:.5rem}.success-box p{margin:.5rem 0}.form-success{opacity:.9}.form-success .field:not(.form-success-message):not(.success-box){display:none}.form-success button[type=submit]{opacity:.6;pointer-events:none}.field-error input,.field-error select,.field-error textarea{border-color:var(--error)}.error-message{color:var(--error);font-size:var(--txt-small);margin-top:.25rem;display:block}.form-error{background-color:var(--errorBack);border:1px solid var(--error);padding:.75rem;border-radius:var(--radius);margin-bottom:1rem}.has-success input,.has-success select,.has-success textarea{border-color:var(--success)}.form-error{display:flex;align-items:center;gap:.5rem}.form-error .error-icon{width:1.25rem;height:1.25rem;flex-shrink:0}.invite details{margin-bottom:1.5rem}.field.tag-list .tag-input-row{display:flex;gap:.5rem;align-items:flex-start;margin-bottom:1rem;flex-wrap:wrap}.field.tag-list .tag-input-row .field{flex:1;min-width:150px;margin:0}.field.tag-list .tag-input-row .add-tag-item{flex-shrink:0;white-space:nowrap;margin-top:calc(var(--txt-medium) + 1rem)}.field.tag-list .tag-items{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem;min-height:2rem}.field.tag-list .tag-item{background:var(--base-200);padding:.4rem .75rem;border-radius:4px;display:inline-flex;align-items:center;gap:.5rem;font-size:.9rem;line-height:1.2}.field.tag-list .tag-item:hover{background:var(--base-100)}.field.tag-list .tag-label{max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.field.tag-list .remove-tag{min-height:0;padding:.25rem;color:var(--contrast);transition:transform .2s;box-shadow:none}.field.tag-list .remove-tag:hover{transform:scale(1.2)}@media (max-width:768px){.field.tag-list .tag-input-row{flex-direction:column;align-items:stretch}.field.tag-list .tag-input-row .field{min-width:100%}}.pendingChanges{position:fixed;bottom:var(--btn);right:var(--btn_);margin-right:1rem;padding:1rem;border-radius:var(--radius);background-color:rgba(var(--base-rgb),var(--op-6));z-index:var(--z-6);width:50vw;animation:fadeInSlideUp .5s ease-out forwards}.pendingChanges button{min-height:0;width:calc(50% - .7rem);padding:.35rem}@keyframes fadeInSlideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}} |
| | | input:is([type=date],[type=number],[type=text],[type=url],[type=email],[type=tel],[type=password],[type=search],[type=datetime-local],[type=time]),textarea{font-family:var(--body);font-size:var(--txt-medium);color:var(--contrast);padding:var(--p-y) var(--p-x);border-radius:var(--radius);background-color:var(--base);outline:0;border:1px solid var(--base-100);border-bottom:2px solid var(--contrast-200);width:100%;max-width:100%;margin:0 4px}input:is([type=date],[type=number],[type=text],[type=url],[type=email],[type=tel],[type=password],[type=search],[type=datetime-local],[type=time]):focus,textarea:focus{outline:var(--action-50);background-color:var(--base-100);color:var(--contrast)}input::placeholder,textarea::placeholder{font-family:var(--body);color:var(--base-200)}@media (min-width:768px){:root{--p-y:1rem}}select{background:var(--base);border:2px solid var(--base-100);border-radius:var(--radius);color:var(--contrast);cursor:pointer;font-family:var(--body);font-size:var(--txt-small);padding:.5rem 1rem;width:100%}select:disabled{background-color:var(--base-50);border-color:var(--base-100);color:var(--base-200);cursor:not-allowed}select option{background:var(--base);color:var(--contrast);padding:.5rem}select option:active,select option:checked,select option:focus,select option:hover{background:var(--action-0);color:var(--base);box-shadow:0 0 0 100px var(--action-0) inset}select option:checked{background:var(--action-0) linear-gradient(0deg,var(--action-0) 0,var(--action-0) 100%);color:var(--base)}select:hover{border-color:var(--action-0)}select:focus{border-color:var(--action-0)}input[type=search]:focus+.clear-search{opacity:1;cursor:pointer}.search-container .clear-search{opacity:0;cursor:default}.search-container .icon.search{padding:4px 8px;color:var(--contrast-200);--w:3rem}input[type=search]::-moz-search-clear-button,input[type=search]::-ms-clear,input[type=search]::-ms-reveal,input[type=search]::search-cancel-button{-webkit-appearance:none;-moz-appearance:none;appearance:none;display:none;visibility:hidden}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration,input[type=search]::-webkit-search-results-button,input[type=search]::-webkit-search-results-decoration{-webkit-appearance:none}input[type=url]{background:var(--linkIcon);background-position:.5em;background-size:1em;background-repeat:no-repeat;padding-left:2em}.integration .label,label{text-transform:uppercase;font-weight:700;margin-bottom:.5rem;display:block}.field{margin:2rem 0;position:relative}.field:has(.has-tooltip) label{margin-left:2rem}legend{padding:0 1rem}.date-wrapper{position:relative;display:inline-block}input[type=date]{padding:8px 36px 8px 8px;border-radius:4px}input[type=date]::-webkit-calendar-picker-indicator{opacity:0;width:100%;height:100%;position:absolute;top:0;left:0;cursor:pointer}input[type=date]+.icon{--w:20px;position:absolute;right:10px;top:50%;transform:translateY(-50%);pointer-events:none}input:is([type=time],[type=datetime-local],[type=date]){padding:.5rem;border:1px solid var(--contrast-200);border-radius:4px;font-size:14px;min-width:180px;background:var(--base);color:var(--contrast);cursor:pointer}.date-wrapper input[type=date]:focus,.datetime-wrapper input[type=datetime-local]:focus,.field-input-wrapper input:is([type=time],[type=datetime-local],[type=date]):focus,.time-wrapper input[type=time]:focus{border-color:var(--action-0);box-shadow:0 0 0 2px rgba(var(--action-rgb),.1)}.date-wrapper .icon,.datetime-wrapper .icon,.field-input-wrapper .icon,.time-wrapper .icon{width:18px;height:18px;background-color:var(--contrast);opacity:.7}[type=checkbox],[type=radio],input.ch{position:absolute;opacity:0;left:-200vw}[type=checkbox]+label,[type=radio]+label,input.ch+label{position:relative;cursor:pointer}[type=checkbox]+label:hover,[type=radio]+label:hover{color:var(--action-0)}[type=checkbox]+label::after,[type=checkbox]+label::before,[type=radio]+label::after,[type=radio]+label::before,input.ch+label::after,input.ch+label::before{content:'';position:absolute;top:50%}[type=checkbox]+label::after,[type=radio]+label::after,input.ch+label::after{left:5px;transform:translateY(-70%) rotate(45deg);width:5px;height:10px;border:solid var(--light-0);border-width:0 2px 2px 0;display:none}[type=checkbox]+label::before,[type=radio]+label::before,input.ch+label::before{left:0;transform:translateY(-50%);width:1rem;height:1rem;border:2px solid var(--contrast-200);background-color:var(--base);border-radius:var(--radius)}[type=checkbox]:hover+label::before,[type=radio]:hover+label::before,input.ch:hover+label::before{border-color:var(--action-200)}[type=checkbox]:checked+label::before,[type=radio]:checked+label::before,input.ch:checked+label::before{background-color:var(--action-0);border-color:var(--action-100)}[type=radio]:checked+label::before{border-radius:50%}[type=checkbox]:checked+label::after,input.ch:checked+label::after{display:block;left:5px;top:50%;transform:translateY(-70%) rotate(45deg);width:.35rem;height:.66rem;border:solid var(--light-0);border-width:0 2px 2px 0}[type=checkbox]:disabled+label,[type=radio]:disabled+label,input.ch:disabled+label{cursor:not-allowed;background-color:var(--base-50);color:var(--base-200);border-color:var(--base-200)}[type=checkbox]:disabled+label:hover,[type=radio]:disabled+label:hover,input.ch:disabled+label:hover{background-color:var(--base-50);color:var(--base-200);border-color:var(--base-200)}[type=checkbox]:disabled+label::before,[type=radio]:disabled+label::before,input.ch:disabled+label::before{border-color:var(--base-200)}[type=checkbox]:not(.btn)+label,[type=radio]:not(.btn)+label,input.ch+label{flex:1;padding-left:2rem;transform-origin:top center;will-change:transform}.btn+label::after,.btn+label::before{display:none}.btn+label{--w:1.2em;border:1px solid var(--base-200);border-radius:var(--radius);min-width:2rem;min-height:2rem;margin:0;display:flex;justify-content:center;align-items:center;flex-wrap:nowrap;gap:.5rem;color:var(--contrast-200);opacity:.8}.radio-options.status label{padding:0 .5rem}.btn:checked+label{border-color:var(--contrast);color:var(--contrast);opacity:1}.btn+label:hover{color:var(--action-50);border-color:var(--action-50)}.btn[hidden]+label,input[hidden]+label{display:none!important}.checkbox-options{--gap:.5rem 2rem}.checkbox-options label{flex:unset!important}.radio-options{--gap:.125rem .5rem}.radio-options input:not(.ch)+label::before{display:none!important}.radio-options input:not(.ch)+label{flex:unset!important;padding:.25rem!important;border-radius:4px;border:1px solid var(--base-100);color:var(--contrast-200);font-weight:400;text-align:center}.radio-options input:not(.ch)+label:hover,.radio-options input:not(.ch):checked+label{border-color:var(--action-0);color:var(--action-0)}.quantity{margin:0;display:inline-flex;width:fit-content;align-items:center;justify-content:center;border:1px solid transparent;border-radius:4px;position:relative}.quantity:focus-within{border-color:var(--action-0)}.quantity label{margin:0;font-size:var(--txt-small)}.quantity button{background:var(--base);padding:0;width:var(--chip_);height:var(--chip_);min-height:0;z-index:0;position:relative;border:1px solid var(--base-200);color:var(--contrast-200)}.quantity button:hover:not(:disabled){color:var(--action-0);border-color:var(--action-0);background-color:var(--base)}.quantity button:active:not(:disabled){background-color:var(--action-0);color:var(--light-0);transform:scale(.95)}.quantity button:disabled{opacity:.5;cursor:not-allowed}.quantity input[type=number]{z-index:1;border:1px solid var(--base-200);background:var(--base);text-align:center;font-size:1.1rem;width:60px;height:48px;margin:0;padding:0!important;appearance:textfield}.quantity input[type=number]::-webkit-inner-spin-button,.quantity input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.quantity input[type=number]:focus{background-color:var(--base-50)}.quantity button.increase{left:-2px;border-radius:0 4px 4px 0}.quantity button.decrease{right:-2px;border-radius:4px 0 0 4px}.tab-content[hidden]{display:block!important;transform:scaleY(0);height:0;overflow:hidden}.tab-content[hidden]:focus-within{transform:scaleY(1);height:auto}nav.tabs h2{margin:0!important;line-height:1;font-size:var(--txt-medium);display:flex;color:var(--contrast);white-space:nowrap;gap:1rem}nav.tabs .active h2{color:var(--action-contrast)}nav.tabs button{padding:.75rem 1.5rem;border-radius:0;position:relative;border:2px solid var(--action-0)}nav.tabs>button:first-of-type{border-top-left-radius:var(--radius)}nav.tabs>button:last-of-type{border-top-right-radius:var(--radius)}.tabs>button:focus,.tabs>button:hover{background-color:var(--base-200)}.tabs>button::after{content:'';position:absolute;bottom:-2px;left:0;width:0;height:3px;background-color:var(--action-50);transition:width .3s}.tabs>button.active::after,.tabs>button:hover::after{width:100%}.tabs>button.active::after{background-color:var(--action-200)}.tabs>button.active{background-color:var(--action-0);color:var(--action-contrast)}.tabs>button.active:focus,.tabs>button.active:hover{background-color:var(--action-100)}.tab-content h2{display:none}details.uploader .file-upload-container{margin:1rem 0;max-width:100%}@media (min-width:768px){details.uploader .file-upload-container{margin:1rem var(--mr) 1rem var(--ml);max-width:var(--content)}}.empty-group,.file-upload-wrapper{border:2px dashed var(--action-0);border-radius:4px;padding:2rem;text-align:center;transition:all .3s ease;background:rgba(var(--action-rgb),var(--op-1));position:relative;cursor:pointer}.file-upload-wrapper h2{margin:0!important;font-size:var(--txt-large)}.dragover,.empty-group,.file-upload-wrapper:hover{background:rgba(var(--action-rgb),var(--op-2));border-color:var(--action-0)!important}.file-upload-wrapper input[type=file]{position:absolute;left:0;top:0;width:100%;height:100%;opacity:0;cursor:pointer}.empty-group p,.file-upload-text{color:var(--contrast);margin:0}.empty-group p strong,.file-upload-text strong{color:var(--action-0);text-decoration:underline}.field.upload{position:relative}.field.upload:not(.uploading) .progress{display:none}.field.upload .actions{position:absolute;top:0;right:0}.item-grid.groups{grid-template-columns:repeat(1,1fr)}.item-grid.group{margin-bottom:0}.item-grid.group .item,.item-grid.preview .item,.item-grid.restore .item{display:block}.item-grid.group button,.item-grid.preview button,.item-grid.restore button{padding:.25rem .5rem}.item-grid.group button .icon,.item-grid.preview button .icon,.item-grid.restore button .icon{--w:1.1em}.item-grid.group .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.preview .item .preview>input[type=checkbox]:not(.label-button)+label,.item-grid.restore .item .preview>input[type=checkbox]:not(.label-button)+label{padding-left:0;margin:0}.item-grid.group .item .preview>input[type=checkbox]+label:before,.item-grid.preview .item .preview>input[type=checkbox]+label:before,.item-grid.restore .item .preview>input[type=checkbox]+label:before{transform:unset;top:.5rem;left:.5rem}.item-grid.group .item .preview>input[type=checkbox]+label::after,.item-grid.preview .item .preview>input[type=checkbox]+label::after,.item-grid.restore .item .preview>input[type=checkbox]+label::after{top:.5rem;left:.75rem;transform:translateY(20%) rotate(45deg)}.item-grid.group .item .item-actions,.item-grid.preview .item .item-actions,.item-grid.restore .item .item-actions{position:absolute;top:0;right:0;padding-left:var(--chipchip)}.item-grid.group summary,.item-grid.preview summary,.item-grid.restore summary{padding:.5rem}.item-grid.group:has([type=checkbox]:checked),.item-grid.preview:has([type=checkbox]:checked),.item-grid.restore:has([type=checkbox]:checked){padding:1rem;background-color:rgba(var(--contrast-rgb),var(--op-1))}.item-grid.group:has([type=checkbox]:checked) .item,.item-grid.preview:has([type=checkbox]:checked) .item,.item-grid.restore:has([type=checkbox]:checked) .item{padding:.75rem;opacity:.8}.item-grid.group:has([type=checkbox]:checked) .item img,.item-grid.preview:has([type=checkbox]:checked) .item img,.item-grid.restore:has([type=checkbox]:checked) .item img{filter:var(--filter)}.item-grid.group:has([type=checkbox]:checked) details,.item-grid.preview:has([type=checkbox]:checked) details,.item-grid.restore:has([type=checkbox]:checked) details{display:none}.item-grid.group .item:has([type=checkbox]:checked),.item-grid.preview .item:has([type=checkbox]:checked),.item-grid.restore .item:has([type=checkbox]:checked){padding:.5rem;background-color:rgba(var(--action-rgb),var(--op-4));opacity:1}.item-grid.preview summary span{display:none}.item-grid.group .item:has([type=checkbox]:checked) img,.item-grid.preview .item:has([type=checkbox]:checked) img,.item-grid.restore .item:has([type=checkbox]:checked) img{filter:none}[type=radio].featured+label .icon-star-fi,[type=radio].featured:checked+label .icon-star{display:none}[type=radio].featured+label .icon-star,[type=radio].featured:checked+label .icon-star-fi{display:inline-block}.restore.restore.item,.upload.upload.item{border-radius:var(--radius);aspect-ratio:unset;overflow:hidden;background:var(--base);border:1px solid var(--base-200)}.restore-item [for=select-item],.upload.item [for=select-item]{aspect-ratio:1}.upload.item:has(details[open]){grid-column:1/-1}.restore.item img,.upload.item img{transition:transform var(--trans-base)}.restore.item:hover img,.upload.item:hover img{transform:scale(1.02);transition:transform var(--trans-base)}.upload-group{background-image:var(--dashed-action);padding:5px;border-radius:var(--radius);background-color:rgba(var(--action-rgb),var(--op-1))}.upload-group .selected .field{margin:0}.upload-group .selection-actions button{aspect-ratio:unset}.submit-uploads{position:fixed;bottom:0;right:var(--btn_);z-index:var(--z-6);height:var(--btn);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);border-radius:var(--radius);animation:pulse-color 5s infinite;animation-delay:1s;background-color:var(--action-0);color:var(--action-contrast)}.submit-uploads:hover{background-color:var(--base-200);color:var(--contrast-200)}.empty-group{order:-1;grid-column:1/-1;padding:20px;background-image:var(--dashed-action);border-radius:var(--radius);margin:10px 0;cursor:pointer;transition:all var(--trans-base);text-align:center;background-color:rgba(var(--action-rgb),var(--op-1))}.group-display:not([hidden])~.file-upload-container{display:none}.dragging,.upload.item.dragging{opacity:.7;transform:scale(.95) rotate(3deg);z-index:var(--z-7);box-shadow:0 8px 25px rgba(0,0,0,.3)}.dragover{background:rgba(var(--action-rgb),var(--op-3))!important;border-color:var(--action-0)!important;transform:scale(1.05);animation:drop-pulse .8s infinite ease-in-out}.drag-preview{position:fixed;z-index:var(--z-9);width:fit-content;overflow:visible;pointer-events:none;opacity:.9;transform:scale(1.05);transition:transform .2s ease}.drag-preview .drag-items{width:max-content;height:max-content;position:relative}.drag-preview .drag-items .drag-item{width:120px;height:120px;position:absolute;top:0;left:0;background:var(--base);border-radius:var(--radius-outer);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.drag-preview .drag-items .drag-item:nth-child(1){transform:rotate(-3deg);z-index:3}.drag-preview .drag-items .drag-item:nth-child(2){left:8px;top:-4px;transform:rotate(4deg);z-index:2;transition-delay:30ms}.drag-preview .drag-items .drag-item:nth-child(3){left:-6px;top:-8px;transform:rotate(-5deg);z-index:1;transition-delay:60ms}.drag-preview .drag-items .drag-item:nth-child(4){left:12px;top:-12px;transform:rotate(3deg);z-index:0;transition-delay:90ms}.drag-preview .drag-items .drag-item:nth-child(n+5){left:-10px;top:-16px;transform:rotate(-4deg);z-index:0;opacity:.8}.drag-preview .drag-items img,.drag-preview .drag-items video{width:100%;height:100%;object-fit:cover;display:block}.drag-preview .drag-count{position:absolute;top:-8px;right:-8px;background:var(--base-200);color:var(--contrast);border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-3)}.item.dragging{opacity:.5;transform:scale(.95);filter:grayscale(50%);transition:opacity .2s ease,transform .2s ease,filter .2s ease}@keyframes drop-pulse{0%,100%{background-color:rgba(var(--action-rgb),var(--op-3));transform:scale(1.02)}50%{background-color:var(rgba(var(--action-rgb),var(--op-4)));transform:scale(1.04)}}.selection-actions{display:flex;gap:.25rem}@media (max-width:767px){body:not(.uploading):has(.group-display:not([hidden])){overflow:hidden}body:not(.uploading):has(.group-display:not([hidden])) .qtoggle{z-index:var(--z-1)}.group-display.group-display{position:fixed;top:var(--btn);bottom:var(--btn);left:0;right:0;max-height:var(--maxHeight);overflow:hidden;z-index:var(--z-6);width:calc(100% - 1rem);height:calc(100% - 1rem);padding:0 0 3rem;--justify:flex-start;--align:flex-start;--gap:0}.group-display::before{content:'';display:block;z-index:-1;top:-.5rem;bottom:-.5rem;left:-.5rem;right:-.5rem;position:absolute;background-color:rgba(var(--base-rgb),var(--op-6));filter:blur(5px)}.group-display .preview-wrap,.group-display .sidebar{--wrap:nowrap;height:50%;overflow:hidden auto;position:relative;padding:.5rem}.group-display .preview-wrap{top:0}.group-display .preview-wrap .selected{display:flex;justify-content:space-between;align-items:center}.group-display .sidebar{bottom:0;flex-wrap:nowrap;overflow:hidden auto;background-color:var(--contrast-200);color:var(--base)}.group-display .sidebar>.hint{color:var(--contrast)}.group-display .sidebar .header{display:none}.group-display .preview-actions{top:0;flex-shrink:0}.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{bottom:0;margin:0;text-align:center}.group-display .preview-actions,.group-display .preview-wrap>.hint,.group-display .sidebar>.hint{position:absolute;left:0;right:0;background-color:rgba(var(--base-rgb),var(--op-6));z-index:var(--z-3);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.group-display .item-grid{height:100%;overflow:hidden auto;grid-template-columns:repeat(3,1fr);padding:2rem 0}.group-display .sidebar>.item-grid{grid-template-columns:repeat(1,1fr);gap:1rem;padding:0}.group-display .sidebar .empty-group{order:0;position:sticky;height:fit-content;top:0;z-index:var(--z-3);background-color:rgba(var(--action-rgb),var(--op-6))}.group-display .sidebar .upload-group{order:1}.group-display .sidebar .empty-group p{margin:0}.group-display .field,.group-display .field label{margin:0;padding:0}.group-display .sidebar h4{margin:.25rem}.group-display .item{width:100%;height:max-content}.submit-uploads{bottom:var(--btn);left:0;right:0;width:100%;height:3rem}body.uploading .group-display.group-display{position:relative;top:unset;bottom:unset;right:unset;left:unset}}@media (min-width:768px){.group-display.group-display{--wrap:nowrap;--dir:row;--gap:1rem;--align:flex-start}.group-display .preview-wrap,.group-display .sidebar{--justify:flex-start;--wrap:nowrap;max-height:calc(100vh - var(--btnbtn));overflow:hidden auto}.group-display .preview-wrap,.group-display .sidebar{width:50%}.preview-actions,.preview-wrap .hint{position:sticky;z-index:var(--z-3);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);background-color:var(--base);width:100%}.preview-actions{top:0;left:0;right:0}.preview-actions .field{margin:0}.preview-wrap .hint,.sidebar>.hint{bottom:-1rem;padding-bottom:1rem;margin:0;left:0;right:0;text-align:center}}.item-grid.restore{grid-template-columns:repeat(1,1fr)}dialog nav.tabs{position:sticky;top:0;background-color:var(--base-50);z-index:var(--z-6);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw-down);margin-bottom:2rem}.editor-container .ql-toolbar{display:flex;background-color:var(--base-50);justify-content:flex-start;flex-wrap:wrap;padding:.25rem;gap:.5rem 1rem;border-top-left-radius:var(--radius);border-top-right-radius:var(--radius);border-bottom:4px solid var(--base-50)}.ql-toolbar .ql-formats{display:flex;gap:.25rem}.editor-container .ql-container{--padding:1rem;background-color:var(--base);border-bottom-left-radius:var(--radius);border-bottom-right-radius:var(--radius);height:fit-content;padding:2px;border:1px solid var(--base-200)}.editor-container .ql-container .ql-editor{padding:var(--padding);width:100%;height:100%}.ql-editor img{max-width:50%;height:auto}.ql-clipboard{left:-100000px;height:1px;overflow-y:hidden;position:absolute;top:50%}.ql-hidden{display:none}.ql-tooltip{position:absolute;transform:translateY(10px);background-color:var(--base-100);border:1px solid var(--base);box-shadow:0 0 5px rgba(var(--base-rgb),var(--op-6));color:var(--contrast);padding:5px 12px;white-space:nowrap}[data-type=single] .item-grid{display:flex}.repeater-row details summary::after{margin-left:0}.repeater-row details summary button{margin-left:auto}/*!* Group actions buttons - more visible *!*//*!* Group item grid - distinct from preview grid *!*//*!* Group count hint *!*//*!* ============================================================================*//*!* Base drag preview *!*//*!* Single item drag preview *!*//*!* Multi-item drag preview container *!*//*!* Items being dragged - reduce opacity on originals *!*//*!* Count badge on multi-item preview *!*//*!* ============================================================================*//*!* Ensure progress bar is visible when needed *!*//*!* Progress bar track *!*//*!* Progress bar fill *!*//*!* Progress details - styled for row layout with text and count *!*//*!* Individual item progress - overlay style *!*//*!* Item progress icon and status text *!*//*!* ============================================================================*//*!* Hide uploader when we have uploads *!*//*!* Show group display when we have uploads *!*//*!* ============================================================================*//*!* Selected items - more obvious *!*//*!* Selection checkbox - always visible on hover or when checked *!*//*!* Selection controls - more prominent *!*//*!* ============================================================================*//*!* Smooth dragover animation *!*//*!* ============================================================================*//*!* ============================================================================*//*!* Notification container - fixed overlay *!*//*!* Content card *!*//*!* Message section *!*//*!* Scrollable field list *!*//*!* Item grid for restore preview *!*//*!* Restore item *!*//*!* Checked state *!*//*!* Preview section *!*//*!* Item info *!*//*!* Checkbox controls *!*//*!* Actions section *!*//*!* Selection controls *!*//*!* Action buttons *!*//*!* Restore button - primary action *!*//*!* Scrap cache button - destructive action *!*//*!* Dismiss button - secondary action *!*//*!* Mobile responsive *!*//*!* Animation *!*//*!* Scrollbar styling for restore field list *!*/form{--step-size:2.5rem}.form-progress{padding:0 1rem}.form-progress .progress{background:var(--base-100);border-radius:var(--radius);padding:1rem}.form-progress .bar{height:6px;background:var(--base-200);border-radius:3px;overflow:hidden;margin-bottom:.5rem}.form-progress .fill{height:100%;background:linear-gradient(90deg,var(--action-0),var(--action-200));width:0%;transition:width .4s ease;border-radius:3px}.form-progress .step-text{font-size:var(--txt-small);font-weight:600;color:var(--contrast-200)}form nav.tabs{position:relative;top:0;left:0;right:0;padding:1rem 0;gap:0;z-index:0}form nav.tabs button{position:relative;background:0 0;border:none;padding:.5rem 1rem .5rem 3rem;z-index:1}form nav.tabs .step-number{width:2.5rem;height:100%;border-radius:50% 0 0 50%;position:absolute;left:0;top:0;background:var(--base-200);color:var(--contrast-50);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:var(--txt-small);border:3px solid var(--base)}form nav.tabs button.pending .step-number{background:var(--base-100);color:var(--contrast-200)}form nav.tabs button.active .step-number,form nav.tabs button.current .step-number{background:var(--action-0);color:var(--action-contrast);border-color:var(--action-200)}form nav.tabs button.completed .step-number{background:var(--successBack);color:var(--successBack);border-color:var(--successText)}form nav.tabs button.completed .step-number::before{content:'✓';font-size:1.2rem;color:var(--successText);position:absolute}form nav.tabs button.completed h2{color:var(--contrast-200)}.step-navigation{margin-top:2rem;padding-top:2rem;border-top:1px solid var(--base-200);gap:1rem}.step-navigation .prev-step{background:var(--base-100)}.step-navigation .next-step,.step-navigation button[type=submit]{margin-left:auto}.field input.error,.field select.error,.field textarea.error{border-color:var(--errorBack)}.error-message{color:var(--errorText);font-size:var(--txt-small);margin-top:.25rem;display:block}@media (max-width:768px){form nav.tabs button{min-width:80px;font-size:var(--txt-small)}form nav.tabs button h2{font-size:var(--txt-small)}form{--step-size:2rem}}.field-input-wrapper{position:relative;display:flex;align-items:center;gap:.5rem}.field-input-wrapper input,.field-input-wrapper select,.field-input-wrapper textarea{flex:1}.validation-icon{display:flex;align-items:center;justify-content:center;font-size:1.25rem;animation:scaleIn .3s ease;--w:1.25rem}.validation-icon.error{color:var(--error)}.validation-icon.success{color:var(--success)}@keyframes scaleIn{from{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}.validation-message{color:var(--error-0);font-size:var(--txt-small);margin-top:.25rem;display:block;animation:slideDown .2s ease}@keyframes slideDown{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.field.has-error input,.field.has-error select,.field.has-error textarea{border-color:var(--error);background-color:var(--errorBack)}.field.has-error input:focus,.field.has-error select:focus,.field.has-error textarea:focus{outline-color:var(--error);box-shadow:0 0 0 3px rgba(var(--error-rgb),.2)}.field.has-success input,.field.has-success select,.field.has-success textarea{border-color:var(--success)}.field label .required{color:var(--error);margin-left:.25rem}.form-summary{padding:2rem;border-radius:8px;margin-top:2rem;border:2px dashed var(--contrast-200)}.form-summary .message{margin-bottom:2rem}.form-summary .result+.result{position:relative;margin-top:1.5rem;padding-top:1.5rem}.form-summary .result+.result::before{position:absolute;top:0;left:16.5%;content:'';width:67%;height:1px;border-bottom:1px solid var(--base-200)}.form-summary h2{margin:1rem 0}.form-summary h4{background-color:var(--base-100);padding:.5rem 2rem;position:relative;left:-2rem;color:var(--contrast-200);font-size:.875rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem}.form-summary p{color:var(--text);margin:0}.group-summary,.repeater-summary{background:var(--base-100);padding:1rem;border-radius:4px;margin-top:.5rem}.repeater-row{margin-bottom:1rem}.repeater-row:last-child{margin-bottom:0}.ql-toolbar button{min-height:0;padding:.5rem}.success-message{color:var(--success);background-color:var(--successBack);border:1px solid var(--success);padding:.75rem 1rem;border-radius:var(--radius);margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.success-message .success-icon{width:1.25rem;height:1.25rem;flex-shrink:0}.success-box{background-color:var(--successBack);border:2px solid var(--success);padding:1.5rem;border-radius:var(--radius-outer);margin-bottom:1rem;text-align:center}.success-box h3{color:var(--success);margin-bottom:.5rem}.success-box p{margin:.5rem 0}.form-success{opacity:.9}.form-success .field:not(.form-success-message):not(.success-box){display:none}.form-success button[type=submit]{opacity:.6;pointer-events:none}.field-error input,.field-error select,.field-error textarea{border-color:var(--error)}.error-message{color:var(--error);font-size:var(--txt-small);margin-top:.25rem;display:block}.form-error{background-color:var(--errorBack);border:1px solid var(--error);padding:.75rem;border-radius:var(--radius);margin-bottom:1rem}.has-success input,.has-success select,.has-success textarea{border-color:var(--success)}.form-error{display:flex;align-items:center;gap:.5rem}.form-error .error-icon{width:1.25rem;height:1.25rem;flex-shrink:0}.invite details{margin-bottom:1.5rem}.field.tag-list .row{margin-bottom:1rem}.field.tag-list .row .field{flex:1;min-width:150px;margin:0}.field.tag-list .tag .add-tag-item{flex-shrink:0;white-space:nowrap;margin-top:calc(var(--txt-medium) + 1rem)}.field.tag-list .tag-items{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem;min-height:2rem}.field.tag-list .tag-item{background:var(--base-200);padding:.4rem .75rem;border-radius:4px;display:inline-flex;align-items:center;gap:.5rem;font-size:.9rem;line-height:1.2}.field.tag-list .tag-item:hover{background:var(--base-100)}.field.tag-list .tag-label{max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.field.tag-list .remove-tag{min-height:0;padding:.25rem;color:var(--contrast);transition:transform .2s;box-shadow:none}.field.tag-list .remove-tag:hover{transform:scale(1.2)}@media (max-width:768px){.field.tag-list .tag{flex-direction:column;align-items:stretch}.field.tag-list .tag .field{min-width:100%}}.pendingChanges{position:fixed;bottom:var(--btn);right:var(--btn_);margin-right:1rem;padding:1rem;border-radius:var(--radius);background-color:rgba(var(--base-rgb),var(--op-6));z-index:var(--z-6);width:50vw;animation:fadeInSlideUp .5s ease-out forwards}.pendingChanges button{min-height:0;width:calc(50% - .7rem);padding:.35rem}@keyframes fadeInSlideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}} |
| | |
| | | .icon-google-logo{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsMTI4YTk0LDk0LDAsMSwxLTIxLjQ5LTU5LjgyLDYsNiwwLDEsMS05LjI1LDcuNjRBODIsODIsMCwxLDAsMjA5Ljc4LDEzNEgxMjhhNiw2LDAsMCwxLDAtMTJoODhBNiw2LDAsMCwxLDIyMiwxMjhaIi8+PC9zdmc+');}.icon-apple-logo{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTkuNCwxNjcuODRDMjAxLjcxLDE1NS42OSwxOTgsMTM1LjEyLDE5OCwxMjBjMC0xOC40MiwxMy44Ni0zNC4yOSwyMi4xMi00Mi4xMmE2LDYsMCwwLDAsMC04LjcxQzIwOCw1Ny43LDE4Ny4wNyw1MCwxNjgsNTBhNzAuMjMsNzAuMjMsMCwwLDAtNDAsMTIuNTUsNjkuNiw2OS42LDAsMCwwLTg5LjMxLDguMDhBNzIuNjMsNzIuNjMsMCwwLDAsMTgsMTIzLjM1YTEyNS4xMSwxMjUuMTEsMCwwLDAsMzkuNTMsODguMzNBMzcuODUsMzcuODUsMCwwLDAsODMuNiwyMjJoODcuN0EzNy44MywzNy44MywwLDAsMCwxOTksMjEwLjA3YTEyMi42LDEyMi42LDAsMCwwLDE3LjU0LTI0LjJjNi41NS0xMiw1Ljc3LTEzLjc1LDUtMTUuNDhBNi4wNyw2LjA3LDAsMCwwLDIxOS40LDE2Ny44NFptLTI5LjIzLDM0QTI1LjgyLDI1LjgyLDAsMCwxLDE3MS4zLDIxMEg4My42QTI1Ljg1LDI1Ljg1LDAsMCwxLDY1Ljc4LDIwMywxMTMuMjEsMTEzLjIxLDAsMCwxLDMwLDEyM2E2MC41NSw2MC41NSwwLDAsMSwxNy4yMS00NEE1Ni44Miw1Ni44MiwwLDAsMSw4OCw2MmguODFhNTcuMzUsNTcuMzUsMCwwLDEsMzUuNDQsMTIuNzEsNiw2LDAsMCwwLDcuNSwwQTU3LjM5LDU3LjM5LDAsMCwxLDE2OCw2MmMxMy44OSwwLDI4LjgxLDQuNjgsMzkuMTEsMTItOS40NCwxMC4xNC0yMS4xLDI2LjU5LTIxLjEsNDYsMCwyMy43OCw3LjgxLDQyLjYsMjIuNjYsNTQuNzdBMTA3LjMzLDEwNy4zMywwLDAsMSwxOTAuMTcsMjAxLjg5Wm0tNjAtMTcxLjM5QTM4LDM4LDAsMCwxLDE2NywyaDFhNiw2LDAsMCwxLDAsMTJoLTFhMjYsMjYsMCwwLDAtMjUuMTgsMTkuNSw2LDYsMCwxLDEtMTEuNjItM1oiLz48L3N2Zz4=');}.icon-check-circle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNzIuMjQsOTkuNzZhNiw2LDAsMCwxLDAsOC40OGwtNTYsNTZhNiw2LDAsMCwxLTguNDgsMGwtMjQtMjRhNiw2LDAsMCwxLDguNDgtOC40OEwxMTIsMTUxLjUxbDUxLjc2LTUxLjc1QTYsNiwwLDAsMSwxNzIuMjQsOTkuNzZaTTIzMCwxMjhBMTAyLDEwMiwwLDEsMSwxMjgsMjYsMTAyLjEyLDEwMi4xMiwwLDAsMSwyMzAsMTI4Wm0tMTIsMGE5MCw5MCwwLDEsMC05MCw5MEE5MC4xLDkwLjEsMCwwLDAsMjE4LDEyOFoiLz48L3N2Zz4=');}.icon-cloud-slash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik01Mi40NCwzNkE2LDYsMCwwLDAsNDMuNTYsNDRsNDAuMTgsNDQuMmMtLjQ1Ljg3LS45LDEuNzUtMS4zMiwyLjY0QTYyLDYyLDAsMSwwLDcyLDIxNGg4OGE4NS4yMyw4NS4yMywwLDAsMCwzMi4zNS02LjNMMjAzLjU2LDIyMGE2LDYsMCwwLDAsOC44OC04LjA4Wk0xNjAsMjAySDcyYTUwLDUwLDAsMSwxLDUuOS05OS42NEE4Ni4yNSw4Ni4yNSwwLDAsMCw3NCwxMjhhNiw2LDAsMCwwLDEyLDAsNzMuOTIsNzMuOTIsMCwwLDEsNi40NC0zMC4ybDkxLjIyLDEwMC4zNEE3My42NSw3My42NSwwLDAsMSwxNjAsMjAyWm04Ni03NGE4NS44NSw4NS44NSwwLDAsMS0yMS44NSw1Ny4yNyw2LDYsMCwwLDEtNC40NywyLDYsNiwwLDAsMS00LjQ3LTEwLDc0LDc0LDAsMCwwLTk5LTEwOC45Miw2LDYsMCwxLDEtNy4xMS05LjY3QTg2LDg2LDAsMCwxLDI0NiwxMjhaIi8+PC9zdmc+');}.icon-exclamation-mark{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNDIsMjAwYTE0LDE0LDAsMSwxLTE0LTE0QTE0LDE0LDAsMCwxLDE0MiwyMDBabS0xNC00MmE2LDYsMCwwLDAsNi02VjQ4YTYsNiwwLDAsMC0xMiwwVjE1MkE2LDYsMCwwLDAsMTI4LDE1OFoiLz48L3N2Zz4=');}.icon-cloud-arrow-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsMTI4YTg1LjI3LDg1LjI3LDAsMCwxLTE3LjIsNTEuNiw2LDYsMCwxLDEtOS42LTcuMkE3NCw3NCwwLDEsMCw4NiwxMjhhNiw2LDAsMCwxLTEyLDAsODUuNTQsODUuNTQsMCwwLDEsMy45MS0yNS42NEE1MC42OCw1MC42OCwwLDAsMCw3MiwxMDJhNTAsNTAsMCwwLDAsMCwxMDBIOTZhNiw2LDAsMCwxLDAsMTJINzJBNjIsNjIsMCwxLDEsODIuNDMsOTAuODgsODYsODYsMCwwLDEsMjQ2LDEyOFptLTY2LjI0LDQzLjc2TDE1OCwxOTMuNTFWMTI4YTYsNiwwLDAsMC0xMiwwdjY1LjUxbC0yMS43Ni0yMS43NWE2LDYsMCwwLDAtOC40OCw4LjQ4bDMyLDMyYTYsNiwwLDAsMCw4LjQ4LDBsMzItMzJhNiw2LDAsMCwwLTguNDgtOC40OFoiLz48L3N2Zz4=');}.icon-caret-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTIuMjQsMTAwLjI0bC04MCw4MGE2LDYsMCwwLDEtOC40OCwwbC04MC04MGE2LDYsMCwwLDEsOC40OC04LjQ4TDEyOCwxNjcuNTFsNzUuNzYtNzUuNzVhNiw2LDAsMCwxLDguNDgsOC40OFoiLz48L3N2Zz4=');}.icon-cloud-arrow-up{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xODguMjQsMTY0LjI0YTYsNiwwLDAsMS04LjQ4LDBMMTU4LDE0Mi40OVYyMDhhNiw2LDAsMCwxLTEyLDBWMTQyLjQ5bC0yMS43NiwyMS43NWE2LDYsMCwwLDEtOC40OC04LjQ4bDMyLTMyYTYsNiwwLDAsMSw4LjQ4LDBsMzIsMzJBNiw2LDAsMCwxLDE4OC4yNCwxNjQuMjRaTTE2MCw0MkE4Ni4xLDg2LjEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDQwYTYsNiwwLDAsMCwwLTEySDcyYTUwLDUwLDAsMCwxLDAtMTAwLDUwLjY4LDUwLjY4LDAsMCwxLDUuOTEuMzZBODUuNTQsODUuNTQsMCwwLDAsNzQsMTI4YTYsNiwwLDAsMCwxMiwwLDc0LDc0LDAsMSwxLDEwMy42LDY3Ljg1LDYsNiwwLDAsMCw0LjgsMTFBODYsODYsMCwwLDAsMTYwLDQyWiIvPjwvc3ZnPg==');}.icon-cloud-check{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjAsNDJBODYuMTEsODYuMTEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDg4YTg2LDg2LDAsMCwwLDAtMTcyWm0wLDE2MEg3MmE1MCw1MCwwLDAsMSwwLTEwMCw1MC42Nyw1MC42NywwLDAsMSw1LjkxLjM1QTg1LjYxLDg1LjYxLDAsMCwwLDc0LDEyOGE2LDYsMCwwLDAsMTIsMCw3NCw3NCwwLDEsMSw3NCw3NFptMzYuMjQtOTQuMjRhNiw2LDAsMCwxLDAsOC40OGwtNDgsNDhhNiw2LDAsMCwxLTguNDgsMGwtMjQtMjRhNiw2LDAsMCwxLDguNDgtOC40OEwxNDQsMTUxLjUxbDQzLjc2LTQzLjc1QTYsNiwwLDAsMSwxOTYuMjQsMTA3Ljc2WiIvPjwvc3ZnPg==');}.icon-cloud-warning{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjAsNDJBODYuMTEsODYuMTEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDg4YTg2LDg2LDAsMCwwLDAtMTcyWm0wLDE2MEg3MmE1MCw1MCwwLDAsMSwwLTEwMCw1MC42Nyw1MC42NywwLDAsMSw1LjkxLjM1QTg1LjYxLDg1LjYxLDAsMCwwLDc0LDEyOGE2LDYsMCwwLDAsMTIsMCw3NCw3NCwwLDEsMSw3NCw3NFptLTYtNzRWODhhNiw2LDAsMCwxLDEyLDB2NDBhNiw2LDAsMCwxLTEyLDBabTE2LDM2YTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDE3MCwxNjRaIi8+PC9zdmc+');}.icon-syncing{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iY3VycmVudENvbG9yIiB2aWV3Qm94PSIwIDAgMjU2IDI1NiI+PHBhdGggaWQ9InJlZnJlc2giIGQ9Ik0xNjAuMDQ3IDEyMi44NzVhMzAuNzg0IDMwLjc4NCAwIDAgMC0yMS43NSA4Ljc5N2MtMi44NDIgMy4wMDMtLjQ2NyA0Ljk3MSAxLjMxMiAzLjE1NiAxMS4wNDMtMTAuNzg2IDI4LjcxLTEwLjY4IDM5LjYyNS4yMzRsNy4yMDMgNy4yMDRoLTEyLjg3NWMtMy4zNDcuMDA4LTMuMTY1IDMuODc1IDAgMy44NzVoMTYuMTFjMi4wNjIgMCAyLjU0LTEuNDE4IDIuNTYyLTQuOTdsLjA5NC0xNC45MjFjLjAyLTMuMjktMy40MzctMy4xNjUtMy40MzcgMHYxMi44NmwtNy4yMDMtNy4xODhhMzAuNzY4IDMwLjc2OCAwIDAgMC0yMS42NDEtOS4wNDd6bS0yOS41OTQgMzkuNzk3Yy0yLjA2MiAwLTIuNTI0IDEuNDAyLTIuNTQ3IDQuOTUzbC0uMDk0IDE0LjkyMmMtLjAyIDMuMjkgMy40MjIgMy4xNjQgMy40MjIgMHYtMTIuODZsNy4yMDMgNy4yMDRjMTEuOTU2IDExLjk1NSAzMS4zMTIgMTIuMDY0IDQzLjQwNy4yNSAyLjg0Mi0zLjAwMy40NTEtNC45ODgtMS4zMjgtMy4xNzItMTEuMDQzIDEwLjc4Ni0yOC43MSAxMC42OC0zOS42MjUtLjIzNWwtNy4xODgtNy4yMDNoMTIuODZjMy4zNDctLjAwOCAzLjE2NS0zLjg2IDAtMy44NmgtMTYuMTF6Ii8+PHBhdGggZD0iTTE2MCA0NGE4NC4xMSA4NC4xMSAwIDAgMC03Ni40MSA0OS4xMkE2MC43MSA2MC43MSAwIDAgMCA3MiA5MmE2MCA2MCAwIDAgMCAwIDEyMGg4OGE4NCA4NCAwIDAgMCAwLTE2OFptMCAxNjBINzJhNTIgNTIgMCAxIDEgOC41NS0xMDMuM0E4My42NiA4My42NiAwIDAgMCA3NiAxMjhhNCA0IDAgMCAwIDggMCA3NiA3NiAwIDEgMSA3NiA3NloiLz48L3N2Zz4=');}.icon-cloud-x{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjAsNDJBODYuMTEsODYuMTEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDg4YTg2LDg2LDAsMCwwLDAtMTcyWm0wLDE2MEg3MmE1MCw1MCwwLDAsMSwwLTEwMCw1MC42Nyw1MC42NywwLDAsMSw1LjkxLjM1QTg1LjYxLDg1LjYxLDAsMCwwLDc0LDEyOGE2LDYsMCwwLDAsMTIsMCw3NCw3NCwwLDEsMSw3NCw3NFptMjguMjQtODUuNzZMMTY4LjQ4LDEzNmwxOS43NiwxOS43NmE2LDYsMCwxLDEtOC40OCw4LjQ4TDE2MCwxNDQuNDhsLTE5Ljc2LDE5Ljc2YTYsNiwwLDAsMS04LjQ4LTguNDhMMTUxLjUyLDEzNmwtMTkuNzYtMTkuNzZhNiw2LDAsMCwxLDguNDgtOC40OEwxNjAsMTI3LjUybDE5Ljc2LTE5Ljc2YTYsNiwwLDAsMSw4LjQ4LDguNDhaIi8+PC9zdmc+');}.icon-arrows-clockwise{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsNDhWOTZhNiw2LDAsMCwxLTYsNkgxNjhhNiw2LDAsMCwxLDAtMTJoMzMuNTJMMTgzLjQ3LDcyYTgxLjUxLDgxLjUxLDAsMCwwLTU3LjUzLTI0aC0uNDZBODEuNSw4MS41LDAsMCwwLDY4LjE5LDcxLjI4YTYsNiwwLDEsMS04LjM4LTguNTgsOTMuMzgsOTMuMzgsMCwwLDEsNjUuNjctMjYuNzZIMTI2YTkzLjQ1LDkzLjQ1LDAsMCwxLDY2LDI3LjUzbDE4LDE4VjQ4YTYsNiwwLDAsMSwxMiwwWk0xODcuODEsMTg0LjcyYTgxLjUsODEuNSwwLDAsMS01Ny4yOSwyMy4zNGgtLjQ2YTgxLjUxLDgxLjUxLDAsMCwxLTU3LjUzLTI0TDU0LjQ4LDE2Nkg4OGE2LDYsMCwwLDAsMC0xMkg0MGE2LDYsMCwwLDAtNiw2djQ4YTYsNiwwLDAsMCwxMiwwVjE3NC40OGwxOCwxOC4wNWE5My40NSw5My40NSwwLDAsMCw2NiwyNy41M2guNTJhOTMuMzgsOTMuMzgsMCwwLDAsNjUuNjctMjYuNzYsNiw2LDAsMSwwLTguMzgtOC41OFoiLz48L3N2Zz4=');}.icon-share-fat{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzYuMjQsMTA3Ljc2bC04MC04MEE2LDYsMCwwLDAsMTQ2LDMyVjc0LjJjLTU0LjQ4LDMuNTktMTIwLjM5LDU1LTEyNy45MywxMjAuNjZhMTAsMTAsMCwwLDAsMTcuMjMsOGgwQzQ2LjU2LDE5MC44NSw4NywxNTIuNiwxNDYsMTUwLjEzVjE5MmE2LDYsMCwwLDAsMTAuMjQsNC4yNGw4MC04MEE2LDYsMCwwLDAsMjM2LjI0LDEwNy43NlpNMTU4LDE3Ny41MlYxNDRhNiw2LDAsMCwwLTYtNmMtMjcuNzMsMC01NC43Niw3LjI1LTgwLjMyLDIxLjU1YTE5My4zOCwxOTMuMzgsMCwwLDAtNDAuODEsMzAuNjVjNC43LTI2LjU2LDIwLjE2LTUyLDQ0LTcyLjI3Qzk4LjQ3LDk3Ljk0LDEyNy4yOSw4NiwxNTIsODZhNiw2LDAsMCwwLDYtNlY0Ni40OUwyMjMuNTEsMTEyWiIvPjwvc3ZnPg==');}.icon-trash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTYsNTBIMTc0VjQwYTIyLDIyLDAsMCwwLTIyLTIySDEwNEEyMiwyMiwwLDAsMCw4Miw0MFY1MEg0MGE2LDYsMCwwLDAsMCwxMkg1MFYyMDhhMTQsMTQsMCwwLDAsMTQsMTRIMTkyYTE0LDE0LDAsMCwwLDE0LTE0VjYyaDEwYTYsNiwwLDAsMCwwLTEyWk05NCw0MGExMCwxMCwwLDAsMSwxMC0xMGg0OGExMCwxMCwwLDAsMSwxMCwxMFY1MEg5NFpNMTk0LDIwOGEyLDIsMCwwLDEtMiwySDY0YTIsMiwwLDAsMS0yLTJWNjJIMTk0Wk0xMTAsMTA0djY0YTYsNiwwLDAsMS0xMiwwVjEwNGE2LDYsMCwwLDEsMTIsMFptNDgsMHY2NGE2LDYsMCwwLDEtMTIsMFYxMDRhNiw2LDAsMCwxLDEyLDBaIi8+PC9zdmc+');}.icon-star{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzcuMjgsOTcuODdBMTQuMTgsMTQuMTgsMCwwLDAsMjI0Ljc2LDg4bC02MC4yNS00Ljg3LTIzLjIyLTU2LjJhMTQuMzcsMTQuMzcsMCwwLDAtMjYuNTgsMEw5MS40OSw4My4xMSwzMS4yNCw4OGExNC4xOCwxNC4xOCwwLDAsMC0xMi41Miw5Ljg5QTE0LjQzLDE0LjQzLDAsMCwwLDIzLDExMy4zMkw2OSwxNTIuOTNsLTE0LDU5LjI1YTE0LjQsMTQuNCwwLDAsMCw1LjU5LDE1LDE0LjEsMTQuMSwwLDAsMCwxNS45MS42TDEyOCwxOTYuMTJsNTEuNTgsMzEuNzFhMTQuMSwxNC4xLDAsMCwwLDE1LjkxLS42LDE0LjQsMTQuNCwwLDAsMCw1LjU5LTE1bC0xNC01OS4yNUwyMzMsMTEzLjMyQTE0LjQzLDE0LjQzLDAsMCwwLDIzNy4yOCw5Ny44N1ptLTEyLjE0LDYuMzctNDguNjksNDJhNiw2LDAsMCwwLTEuOTIsNS45MmwxNC44OCw2Mi43OWEyLjM1LDIuMzUsMCwwLDEtLjk1LDIuNTcsMi4yNCwyLjI0LDAsMCwxLTIuNi4xTDEzMS4xNCwxODRhNiw2LDAsMCwwLTYuMjgsMEw3MC4xNCwyMTcuNjFhMi4yNCwyLjI0LDAsMCwxLTIuNi0uMSwyLjM1LDIuMzUsMCwwLDEtMS0yLjU3bDE0Ljg4LTYyLjc5YTYsNiwwLDAsMC0xLjkyLTUuOTJsLTQ4LjY5LTQyYTIuMzcsMi4zNywwLDAsMS0uNzMtMi42NSwyLjI4LDIuMjgsMCwwLDEsMi4wNy0xLjY1bDYzLjkyLTUuMTZhNiw2LDAsMCwwLDUuMDYtMy42OWwyNC42My01OS42YTIuMzUsMi4zNSwwLDAsMSw0LjM4LDBsMjQuNjMsNTkuNmE2LDYsMCwwLDAsNS4wNiwzLjY5bDYzLjkyLDUuMTZhMi4yOCwyLjI4LDAsMCwxLDIuMDcsMS42NUEyLjM3LDIuMzcsMCwwLDEsMjI1LjE0LDEwNC4yNFoiLz48L3N2Zz4=');}.icon-alphabetical{--icon:url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIGZpbGw9ImN1cnJlbnRDb2xvciIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIDAgMTgzLjc4IDE4NC4wNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJtNTkuNTg2IDY5Ljc0MmMtMC44NTEzIDAtMS40NjEgMC4xOTY1Ni0xLjgzNjYgMC41OTcxOC0wLjM1MDU0IDAuMzc1NTgtMC41Mjk1OCAxLjAyMjktMC41Mjk1OCAxLjk0OTNzMC4xNzkwMyAxLjU5MzcgMC41Mjk1OCAxLjk5NDRjMC4zNzU1OCAwLjM3NTU4IDAuOTg1MjkgMC41NjMzOCAxLjgzNjYgMC41NjMzOGg3LjAxOTdsLTEyLjQyOCAzNC4zNjZoLTIuMTA3Yy0wLjg1MTMgMC0xLjQ2MSAwLjE5NjU2LTEuODM2NiAwLjU5NzE4LTAuMzUwNTQgMC4zNzU1OC0wLjUyOTU3IDEuMDM0MS0wLjUyOTU3IDEuOTYwNiAwIDAuOTI2NDQgMC4xNzkwMyAxLjU4MjUgMC41Mjk1NyAxLjk4MyAwLjM3NTU4IDAuMzc1NTkgMC45ODUyOSAwLjU2MzM4IDEuODM2NiAwLjU2MzM4aDEyLjU1MmMwLjg1MTMgMCAxLjQ1MjItMC4xODc3OSAxLjgwMjgtMC41NjMzOCAwLjM3NTU4LTAuNDAwNjIgMC41NjMzNy0xLjA1NjYgMC41NjMzNy0xLjk4MyAwLTAuOTI2NDUtMC4xODc3OS0xLjU4NS0wLjU2MzM3LTEuOTYwNi0wLjM1MDU0LTAuNDAwNjItMC45NTE0Ny0wLjU5NzE4LTEuODAyOC0wLjU5NzE4aC00LjU1MjFsMy4xMjExLTguOTM0OWgxOC4yMmwzLjA3NiA4LjkzNDloLTUuMDcwNGMtMC44NTEzIDAtMS40NjEgMC4xOTY1Ni0xLjgzNjYgMC41OTcxOC0wLjM1MDU0IDAuMzc1NTgtMC41Mjk1OCAxLjAzNDEtMC41Mjk1OCAxLjk2MDYgMCAwLjkyNjQ0IDAuMTc5MDMgMS41ODI1IDAuNTI5NTggMS45ODMgMC4zNzU1OCAwLjM3NTU5IDAuOTg1MjkgMC41NjMzOCAxLjgzNjYgMC41NjMzOGgxMy4yOTZjMC44NTEzIDAgMS40NTIyLTAuMTg3NzkgMS44MDI4LTAuNTYzMzggMC4zNzU1OC0wLjQwMDYyIDAuNTYzMzctMS4wNTY2IDAuNTYzMzctMS45ODMgMC0wLjkyNjQ1LTAuMTg3NzktMS41ODUtMC41NjMzNy0xLjk2MDYtMC4zNTA1NC0wLjQwMDYyLTAuOTUxNDctMC41OTcxOC0xLjgwMjgtMC41OTcxOGgtMi4yODczbC0xMy4yNjItMzcuMDM2Yy0wLjMwMDQ3LTAuODUxMy0wLjc1OTk0LTEuNDYxLTEuMzg1OS0xLjgzNjYtMC42MDA5My0wLjQwMDYyLTEuNDA5Ny0wLjU5NzE4LTIuNDExMy0wLjU5NzE4em00NC4xNDYgMGMtMC44NTEzIDAtMS40NzIzIDAuMTk2NTYtMS44NDc4IDAuNTk3MTgtMC4zNTA1NSAwLjM3NTU4LTAuNTE4MyAxLjAyMjktMC41MTgzIDEuOTQ5M3YxMS45MWMwIDAuODc2MzMgMC4yMDUzMiAxLjUwNjEgMC42MzA5OCAxLjg4MTcgMC40MjU2NiAwLjM3NTU4IDEuMTU5MyAwLjU2MzM3IDIuMTg1OSAwLjU2MzM3czEuNzQ5LTAuMTg3NzkgMi4xNzQ3LTAuNTYzMzdjMC40MjU2OS0wLjM3NTU4IDAuNjQyMjYtMS4wMDUzIDAuNjQyMjYtMS44ODE3di05LjM1MTdoMTguODUxbC0yNC43NTQgMzUuMzAxYy0wLjM1MDU0IDAuNTI1ODItMC41MTgzMSAxLjA3MTctMC41MTgzMSAxLjYyMjYgMCAwLjkyNjQ1IDAuMTY3NzcgMS41ODI1IDAuNTE4MzEgMS45ODMxIDAuMzc1NTggMC4zNzU1OCAwLjk5NjU0IDAuNTYzMzggMS44NDc4IDAuNTYzMzhoMjguNzY2YzAuODUxMyAwIDEuNDUyMi0wLjE4NzggMS44MDI4LTAuNTYzMzggMC4zNzU1OC0wLjQwMDYyIDAuNTYzMzgtMS4wNTY2IDAuNTYzMzgtMS45ODMxdi0xMi42NjVjMC0wLjg3NjMzLTAuMjE2NTgtMS40OTQ4LTAuNjQyMjUtMS44NzA0LTAuNDI1NjYtMC4zNzU1OC0xLjE0OC0wLjU2MzM4LTIuMTc0Ny0wLjU2MzM4LTEuMDI2NiAwLTEuNzQ5IDAuMTg3NzktMi4xNzQ3IDAuNTYzMzgtMC40MjU2NiAwLjM3NTU4LTAuNjQyMjQgMC45OTQwMi0wLjY0MjI0IDEuODcwNHYxMC4xMDdoLTE5Ljk3OGwyNC45MDEtMzUuNDU5YzAuMjUwMzktMC4zNTA1NCAwLjM3MTgzLTAuODM4ODMgMC4zNzE4My0xLjQ2NDggMC0wLjkyNjQ1LTAuMTg3OC0xLjU3MzctMC41NjMzOC0xLjk0OTMtMC4zNTA1NS0wLjQwMDYyLTAuOTUxNDctMC41OTcxOC0xLjgwMjgtMC41OTcxOHptLTMxLjc1MiA1LjEwNDJoMC43MDk4NWw2Ljk4NTkgMjAuMzE1aC0xNC43MTZ6bS0zNy43MjMtNDkuMTgzYy00LjczNDIgMC04LjYzMTMgMy44OTctOC42MzEzIDguNjMxM3YxMTUuNDdjMCA0LjczNDIgMy44OTcgOC42MzEzIDguNjMxMyA4LjYzMTNoMTE1LjI2YzQuNzM0MiAwIDguNjQyMS0zLjg5NyA4LjY0MjEtOC42MzEzdi0xMTUuNDdjMC00LjczNDItMy45MDgyLTguNjMxMy04LjY0MjEtOC42MzEzem0wIDUuNzI0aDExNS4yNmMxLjY1OCAwIDIuOTA3IDEuMjQ5MSAyLjkwNyAyLjkwNzF2MTE1LjQ3YzAgMS42NTgtMS4yNDkxIDIuOTA3LTIuOTA3IDIuOTA3aC0xMTUuMjZjLTEuNjU4IDAtMi44OTU4LTEuMjQ5MS0yLjg5NTgtMi45MDd2LTExNS40N2MwLTEuNjU4IDEuMjM3OC0yLjkwNzEgMi44OTU4LTIuOTA3MXoiIGZpbGw9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIuNzIxMTQiLz48L3N2Zz4=');}.icon-scribble{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDQuMjUsMTg4LjI0YTE2LjYzLDE2LjYzLDAsMCwwLDAsMjMuNTIsNiw2LDAsMSwxLTguNDgsOC40OCwyOC42MSwyOC42MSwwLDAsMSwwLTQwLjQ4bDkuMzctOS4zOGExNi42MywxNi42MywwLDAsMC0yMy41Mi0yMy41MWwtNjYuNzUsNjYuNzVhMjguNjMsMjguNjMsMCwwLDEtNDAuNDktNDAuNDlsOTguNzYtOTguNzVhMTYuNjMsMTYuNjMsMCwwLDAtMjMuNTItMjMuNTFMODIuODYsMTE3LjYyQTI4LjYzLDI4LjYzLDAsMCwxLDQyLjM3LDc3LjEzTDgzLjc1LDM1Ljc2YTYsNiwwLDEsMSw4LjQ5LDguNDhMNTAuODYsODUuNjJhMTYuNjMsMTYuNjMsMCwwLDAsMjMuNTIsMjMuNTFsNjYuNzUtNjYuNzVhMjguNjMsMjguNjMsMCwwLDEsNDAuNDksNDAuNDlMODIuODYsMTgxLjYyYTE2LjYzLDE2LjYzLDAsMCwwLDIzLjUyLDIzLjUxbDY2Ljc2LTY2Ljc1YTI4LjYzLDI4LjYzLDAsMCwxLDQwLjQ5LDQwLjQ5WiIvPjwvc3ZnPg==');}.icon-brackets-angle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik04NS4wNiw0My4yMiwzMS4xMSwxMjhsNTQsODQuNzhhNiw2LDAsMCwxLTEuODQsOC4yOCw2LDYsMCwwLDEtOC4yOC0xLjg0bC01Ni04OGE2LDYsMCwwLDEsMC02LjQ0bDU2LTg4YTYsNiwwLDAsMSwxMC4xMiw2LjQ0Wm0xNTIsODEuNTYtNTYtODhhNiw2LDAsMSwwLTEwLjEyLDYuNDRMMjI0Ljg5LDEyOGwtNTMuOTUsODQuNzhhNiw2LDAsMCwwLDEuODQsOC4yOCw2LDYsMCwwLDAsOC4yOC0xLjg0bDU2LTg4QTYsNiwwLDAsMCwyMzcuMDYsMTI0Ljc4WiIvPjwvc3ZnPg==');}.icon-brain{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsMTI0YTU0LjEzLDU0LjEzLDAsMCwwLTMyLTQ5LjMzVjcyYTQ2LDQ2LDAsMCwwLTg2LTIyLjY3QTQ2LDQ2LDAsMCwwLDQyLDcydjIuNjdhNTQsNTQsMCwwLDAsMCw5OC42M1YxNzZhNDYsNDYsMCwwLDAsODYsMjIuNjdBNDYsNDYsMCwwLDAsMjE0LDE3NnYtMi43QTU0LjA3LDU0LjA3LDAsMCwwLDI0NiwxMjRaTTg4LDIxMGEzNCwzNCwwLDAsMS0zNC0zMi45NEE1My42Nyw1My42NywwLDAsMCw2NCwxNzhoOGE2LDYsMCwwLDAsMC0xMkg2NEE0Miw0MiwwLDAsMSw1MCw4NC4zOWE2LDYsMCwwLDAsNC01LjY2VjcyYTM0LDM0LDAsMCwxLDY4LDB2NzMuMDVBNDUuODksNDUuODksMCwwLDAsODgsMTMwYTYsNiwwLDAsMCwwLDEyLDM0LDM0LDAsMCwxLDAsNjhabTEwNC00NGgtOGE2LDYsMCwwLDAsMCwxMmg4YTUzLjY3LDUzLjY3LDAsMCwwLDEwLS45NEEzNCwzNCwwLDEsMSwxNjgsMTQyYTYsNiwwLDAsMCwwLTEyLDQ1Ljg5LDQ1Ljg5LDAsMCwwLTM0LDE1LjA1VjcyYTM0LDM0LDAsMCwxLDY4LDB2Ni43M2E2LDYsMCwwLDAsNCw1LjY2QTQyLDQyLDAsMCwxLDE5MiwxNjZabTE0LTU0YTYsNiwwLDAsMS02LDZoLTRhMzQsMzQsMCwwLDEtMzQtMzRWODBhNiw2LDAsMCwxLDEyLDB2NGEyMiwyMiwwLDAsMCwyMiwyMmg0QTYsNiwwLDAsMSwyMDYsMTEyWk02MCwxMThINTZhNiw2LDAsMCwxLDAtMTJoNEEyMiwyMiwwLDAsMCw4Miw4NFY4MGE2LDYsMCwwLDEsMTIsMHY0QTM0LDM0LDAsMCwxLDYwLDExOFoiLz48L3N2Zz4=');}.icon-palette{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xOTkuMzcsNTUuMzFBMTAxLjMyLDEwMS4zMiwwLDAsMCwxMjgsMjZoLTFBMTAyLDEwMiwwLDAsMCwyNiwxMjhjMCw0Mi4wOSwyNi4wNyw3Ny40NCw2OCw5Mi4yNkEzMC4yMSwzMC4yMSwwLDAsMCwxMDQuMTEsMjIyLDMwLjA2LDMwLjA2LDAsMCwwLDEzNCwxOTJhMTgsMTgsMCwwLDEsMTgtMThoNDYuMjFhMjkuODIsMjkuODIsMCwwLDAsMjkuMjUtMjMuMzFBMTAyLjcxLDEwMi43MSwwLDAsMCwyMzAsMTI3LjExLDEwMS4yNSwxMDEuMjUsMCwwLDAsMTk5LjM3LDU1LjMxWk0yMTUuNzYsMTQ4YTE3Ljg5LDE3Ljg5LDAsMCwxLTE3LjU1LDE0SDE1MmEzMCwzMCwwLDAsMC0zMCwzMCwxOCwxOCwwLDAsMS0yNCwxN0M2MSwxOTUuODYsMzgsMTY0Ljg1LDM4LDEyOGE5MCw5MCwwLDAsMSw4OS4wNy05MEgxMjhhOTAuMzQsOTAuMzQsMCwwLDEsOTAsODkuMjJBOTAuNDYsOTAuNDYsMCwwLDEsMjE1Ljc2LDE0OFpNMTM4LDc2YTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDEzOCw3NlpNOTQsMTAwQTEwLDEwLDAsMSwxLDg0LDkwLDEwLDEwLDAsMCwxLDk0LDEwMFptMCw1NmExMCwxMCwwLDEsMS0xMC0xMEExMCwxMCwwLDAsMSw5NCwxNTZabTg4LTU2YTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDE4MiwxMDBaIi8+PC9zdmc+');}.icon-pen-nib{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsOTIuNjhhMTMuOTQsMTMuOTQsMCwwLDAtNC4xLTkuOUwxNzMuMjEsMTQuMWExNCwxNCwwLDAsMC0xOS44LDBMMTI0LjY4LDQyLjgzLDY2LjIyLDY0Ljc2YTE0LDE0LDAsMCwwLTguOSwxMC44TDM0LjA4LDIxNUE2LDYsMCwwLDAsNDAsMjIyYTYuNjEsNi42MSwwLDAsMCwxLS4wOGwxMzkuNDQtMjMuMjRhMTQsMTQsMCwwLDAsMTAuODEtOC45bDIxLjkyLTU4LjQ2LDI4Ljc0LTI4Ljc0QTEzLjkyLDEzLjkyLDAsMCwwLDI0Niw5Mi42OFptLTY2LDkyLjg5YTIsMiwwLDAsMS0xLjU0LDEuMjdMNTcuNDksMjA3bDUyLjg3LTUyLjg4YTI2LDI2LDAsMSwwLTguNDgtOC40OEw0OSwxOTguNTNsMjAuMTctMTIxQTIsMiwwLDAsMSw3MC40Myw3Nmw1Ni4wNi0yMUwyMDEsMTI5LjUxWk0xMTAsMTMyYTE0LDE0LDAsMSwxLDE0LDE0QTE0LDE0LDAsMCwxLDExMCwxMzJaTTIzMy40MSw5NC4xLDIwOCwxMTkuNTEsMTM2LjQ4LDQ4LDE2MS45LDIyLjU4YTIsMiwwLDAsMSwyLjgzLDBsNjguNjgsNjguNjlhMiwyLDAsMCwxLDAsMi44M1oiLz48L3N2Zz4=');}.icon-question{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMzgsMTgwYTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDEzOCwxODBaTTEyOCw3NGMtMjEsMC0zOCwxNS4yNS0zOCwzNHY0YTYsNiwwLDAsMCwxMiwwdi00YzAtMTIuMTMsMTEuNjYtMjIsMjYtMjJzMjYsOS44NywyNiwyMi0xMS42NiwyMi0yNiwyMmE2LDYsMCwwLDAtNiw2djhhNiw2LDAsMCwwLDEyLDB2LTIuNDJjMTguMTEtMi41OCwzMi0xNi42NiwzMi0zMy41OEMxNjYsODkuMjUsMTQ5LDc0LDEyOCw3NFptMTAyLDU0QTEwMiwxMDIsMCwxLDEsMTI4LDI2LDEwMi4xMiwxMDIuMTIsMCwwLDEsMjMwLDEyOFptLTEyLDBhOTAsOTAsMCwxLDAtOTAsOTBBOTAuMSw5MC4xLDAsMCwwLDIxOCwxMjhaIi8+PC9zdmc+');}.icon-city{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDAsMjEwSDIzMFY4OGE2LDYsMCwwLDAtNi02SDE2MGE2LDYsMCwwLDAtNiw2djQySDEwMlY0MGE2LDYsMCwwLDAtNi02SDMyYTYsNiwwLDAsMC02LDZWMjEwSDE2YTYsNiwwLDAsMCwwLDEySDI0MGE2LDYsMCwwLDAsMC0xMlpNMTY2LDk0aDUyVjIxMEgxNjZabS0xMiw0OHY2OEgxMDJWMTQyWk0zOCw0Nkg5MFYyMTBIMzhaTTcwLDcyVjg4YTYsNiwwLDAsMS0xMiwwVjcyYTYsNiwwLDAsMSwxMiwwWm0wLDQ4djE2YTYsNiwwLDAsMS0xMiwwVjEyMGE2LDYsMCwwLDEsMTIsMFptMCw0OHYxNmE2LDYsMCwwLDEtMTIsMFYxNjhhNiw2LDAsMCwxLDEyLDBabTUyLDE2VjE2OGE2LDYsMCwwLDEsMTIsMHYxNmE2LDYsMCwwLDEtMTIsMFptNjQsMFYxNjhhNiw2LDAsMCwxLDEyLDB2MTZhNiw2LDAsMCwxLTEyLDBabTAtNDhWMTIwYTYsNiwwLDAsMSwxMiwwdjE2YTYsNiwwLDAsMS0xMiwwWiIvPjwvc3ZnPg==');}.icon-folder{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTYsNzRIMTMwLjQ5bC0yNy45LTI3LjlhMTMuOTQsMTMuOTQsMCwwLDAtOS45LTQuMUg0MEExNCwxNCwwLDAsMCwyNiw1NlYyMDAuNjJBMTMuMzksMTMuMzksMCwwLDAsMzkuMzgsMjE0SDIxNi44OUExMy4xMiwxMy4xMiwwLDAsMCwyMzAsMjAwLjg5Vjg4QTE0LDE0LDAsMCwwLDIxNiw3NFpNNDAsNTRIOTIuNjlhMiwyLDAsMCwxLDEuNDEuNTlMMTEzLjUxLDc0SDM4VjU2QTIsMiwwLDAsMSw0MCw1NFpNMjE4LDIwMC44OWExLjExLDEuMTEsMCwwLDEtMS4xMSwxLjExSDM5LjM4QTEuNCwxLjQsMCwwLDEsMzgsMjAwLjYyVjg2SDIxNmEyLDIsMCwwLDEsMiwyWiIvPjwvc3ZnPg==');}.icon-hash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjQsOTBIMTczbDguODktNDguOTNhNiw2LDAsMSwwLTExLjgtMi4xNEwxNjAuODEsOTBIMTA5bDguODktNDguOTNhNiw2LDAsMCwwLTExLjgtMi4xNEw5Ni44MSw5MEg0OGE2LDYsMCwwLDAsMCwxMkg5NC42M2wtOS40Niw1MkgzMmE2LDYsMCwwLDAsMCwxMkg4M0w3NC4xLDIxNC45M2E2LDYsMCwwLDAsNC44Myw3QTUuNjQsNS42NCwwLDAsMCw4MCwyMjJhNiw2LDAsMCwwLDUuODktNC45M0w5NS4xOSwxNjZIMTQ3bC04Ljg5LDQ4LjkzYTYsNiwwLDAsMCw0LjgzLDcsNS42NCw1LjY0LDAsMCwwLDEuMDguMSw2LDYsMCwwLDAsNS44OS00LjkzTDE1OS4xOSwxNjZIMjA4YTYsNiwwLDAsMCwwLTEySDE2MS4zN2w5LjQ2LTUySDIyNGE2LDYsMCwwLDAsMC0xMlptLTc0LjgzLDY0SDk3LjM3bDkuNDYtNTJoNTEuOFoiLz48L3N2Zz4=');}.icon-shapes{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik02OS42OSw2Mi4xYTYsNiwwLDAsMC0xMS4zOCwwbC00MCwxMjBBNiw2LDAsMCwwLDI0LDE5MGg4MGE2LDYsMCwwLDAsNS42OS03LjlaTTMyLjMyLDE3OCw2NCw4M2wzMS42OCw5NVpNMjA2LDc2YTUwLDUwLDAsMSwwLTUwLDUwQTUwLjA2LDUwLjA2LDAsMCwwLDIwNiw3NlptLTg4LDBhMzgsMzgsMCwxLDEsMzgsMzhBMzgsMzgsMCwwLDEsMTE4LDc2Wm0xMDYsNzBIMTM2YTYsNiwwLDAsMC02LDZ2NTZhNiw2LDAsMCwwLDYsNmg4OGE2LDYsMCwwLDAsNi02VjE1MkE2LDYsMCwwLDAsMjI0LDE0NlptLTYsNTZIMTQyVjE1OGg3NloiLz48L3N2Zz4=');}.icon-diamonds-four{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjMuNzYsMTA4LjI0YTYsNiwwLDAsMCw4LjQ4LDBsNDAtNDBhNiw2LDAsMCwwLDAtOC40OGwtNDAtNDBhNiw2LDAsMCwwLTguNDgsMGwtNDAsNDBhNiw2LDAsMCwwLDAsOC40OFpNMTI4LDMyLjQ5LDE1OS41MSw2NCwxMjgsOTUuNTEsOTYuNDksNjRabTQuMjQsMTE1LjI3YTYsNiwwLDAsMC04LjQ4LDBsLTQwLDQwYTYsNiwwLDAsMCwwLDguNDhsNDAsNDBhNiw2LDAsMCwwLDguNDgsMGw0MC00MGE2LDYsMCwwLDAsMC04LjQ4Wk0xMjgsMjIzLjUxLDk2LjQ5LDE5MiwxMjgsMTYwLjQ5LDE1OS41MSwxOTJabTEwOC4yNC05OS43NS00MC00MGE2LDYsMCwwLDAtOC40OCwwbC00MCw0MGE2LDYsMCwwLDAsMCw4LjQ4bDQwLDQwYTYsNiwwLDAsMCw4LjQ4LDBsNDAtNDBBNiw2LDAsMCwwLDIzNi4yNCwxMjMuNzZaTTE5MiwxNTkuNTEsMTYwLjQ5LDEyOCwxOTIsOTYuNDksMjIzLjUxLDEyOFptLTgzLjc2LTM1Ljc1LTQwLTQwYTYsNiwwLDAsMC04LjQ4LDBsLTQwLDQwYTYsNiwwLDAsMCwwLDguNDhsNDAsNDBhNiw2LDAsMCwwLDguNDgsMGw0MC00MEE2LDYsMCwwLDAsMTA4LjI0LDEyMy43NlpNNjQsMTU5LjUxLDMyLjQ5LDEyOCw2NCw5Ni40OSw5NS41MSwxMjhaIi8+PC9zdmc+');}.icon-crosshair-simple{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjgsMjZBMTAyLDEwMiwwLDEsMCwyMzAsMTI4LDEwMi4xMiwxMDIuMTIsMCwwLDAsMTI4LDI2Wm02LDE5MS44VjE4NGE2LDYsMCwwLDAtMTIsMHYzMy44QTkwLjE1LDkwLjE1LDAsMCwxLDM4LjIsMTM0SDcyYTYsNiwwLDAsMCwwLTEySDM4LjJBOTAuMTUsOTAuMTUsMCwwLDEsMTIyLDM4LjJWNzJhNiw2LDAsMCwwLDEyLDBWMzguMkE5MC4xNSw5MC4xNSwwLDAsMSwyMTcuOCwxMjJIMTg0YTYsNiwwLDAsMCwwLDEyaDMzLjhBOTAuMTUsOTAuMTUsMCwwLDEsMTM0LDIxNy44WiIvPjwvc3ZnPg==');}.icon-circle-notch{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzAsMTI4YTEwMiwxMDIsMCwwLDEtMjA0LDBjMC00MC4xOCwyMy4zNS03Ni44Niw1OS41LTkzLjQ1YTYsNiwwLDAsMSw1LDEwLjlDNTguNjEsNjAuMDksMzgsOTIuNDksMzgsMTI4YTkwLDkwLDAsMCwwLDE4MCwwYzAtMzUuNTEtMjAuNjEtNjcuOTEtNTIuNS04Mi41NWE2LDYsMCwwLDEsNS0xMC45QzIwNi42NSw1MS4xNCwyMzAsODcuODIsMjMwLDEyOFoiLz48L3N2Zz4=');}.icon-cards-three{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsOTBINDhhMTQsMTQsMCwwLDAtMTQsMTR2OTZhMTQsMTQsMCwwLDAsMTQsMTRIMjA4YTE0LDE0LDAsMCwwLDE0LTE0VjEwNEExNCwxNCwwLDAsMCwyMDgsOTBabTIsMTEwYTIsMiwwLDAsMS0yLDJINDhhMiwyLDAsMCwxLTItMlYxMDRhMiwyLDAsMCwxLDItMkgyMDhhMiwyLDAsMCwxLDIsMlpNNTAsNjRhNiw2LDAsMCwxLDYtNkgyMDBhNiw2LDAsMCwxLDAsMTJINTZBNiw2LDAsMCwxLDUwLDY0Wk02NiwzMmE2LDYsMCwwLDEsNi02SDE4NGE2LDYsMCwwLDEsMCwxMkg3MkE2LDYsMCwwLDEsNjYsMzJaIi8+PC9zdmc+');}.icon-sun-dim{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjIsNDBWMzJhNiw2LDAsMCwxLDEyLDB2OGE2LDYsMCwwLDEtMTIsMFptNjgsODhhNjIsNjIsMCwxLDEtNjItNjJBNjIuMDcsNjIuMDcsMCwwLDEsMTkwLDEyOFptLTEyLDBhNTAsNTAsMCwxLDAtNTAsNTBBNTAuMDYsNTAuMDYsMCwwLDAsMTc4LDEyOFpNNTkuNzYsNjguMjRhNiw2LDAsMSwwLDguNDgtOC40OGwtOC04YTYsNiwwLDAsMC04LjQ4LDguNDhabTAsMTE5LjUyLTgsOGE2LDYsMCwxLDAsOC40OCw4LjQ4bDgtOGE2LDYsMCwxLDAtOC40OC04LjQ4Wm0xMzYtMTM2LTgsOGE2LDYsMCwxLDAsOC40OCw4LjQ4bDgtOGE2LDYsMCwwLDAtOC40OC04LjQ4Wm0uNDgsMTM2YTYsNiwwLDAsMC04LjQ4LDguNDhsOCw4YTYsNiwwLDAsMCw4LjQ4LTguNDhaTTQwLDEyMkgzMmE2LDYsMCwwLDAsMCwxMmg4YTYsNiwwLDAsMCwwLTEyWm04OCw4OGE2LDYsMCwwLDAtNiw2djhhNiw2LDAsMCwwLDEyLDB2LThBNiw2LDAsMCwwLDEyOCwyMTBabTk2LTg4aC04YTYsNiwwLDAsMCwwLDEyaDhhNiw2LDAsMCwwLDAtMTJaIi8+PC9zdmc+');}.icon-moon{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzIuMTMsMTQzLjY0YTYsNiwwLDAsMC02LTEuNDlBOTAuMDcsOTAuMDcsMCwwLDEsMTEzLjg2LDI5Ljg1YTYsNiwwLDAsMC03LjQ5LTcuNDhBMTAyLjg4LDEwMi44OCwwLDAsMCw1NC40OCw1OC42OCwxMDIsMTAyLDAsMCwwLDE5Ny4zMiwyMDEuNTJhMTAyLjg4LDEwMi44OCwwLDAsMCwzNi4zMS01MS44OUE2LDYsMCwwLDAsMjMyLjEzLDE0My42NFptLTQyLDQ4LjI5YTkwLDkwLDAsMCwxLTEyNi0xMjZBOTAuOSw5MC45LDAsMCwxLDk5LjY1LDM3LjY2LDEwMi4wNiwxMDIuMDYsMCwwLDAsMjE4LjM0LDE1Ni4zNSw5MC45LDkwLjksMCwwLDEsMTkwLjEsMTkxLjkzWiIvPjwvc3ZnPg==');}.icon-plus-square{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMzRINDhBMTQsMTQsMCwwLDAsMzQsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFY0OEExNCwxNCwwLDAsMCwyMDgsMzRabTIsMTc0YTIsMiwwLDAsMS0yLDJINDhhMiwyLDAsMCwxLTItMlY0OGEyLDIsMCwwLDEsMi0ySDIwOGEyLDIsMCwwLDEsMiwyWm0tMzYtODBhNiw2LDAsMCwxLTYsNkgxMzR2MzRhNiw2LDAsMCwxLTEyLDBWMTM0SDg4YTYsNiwwLDAsMSwwLTEyaDM0Vjg4YTYsNiwwLDAsMSwxMiwwdjM0aDM0QTYsNiwwLDAsMSwxNzQsMTI4WiIvPjwvc3ZnPg==');}.icon-arrow-elbow-left-up{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzgsMTkyYTYsNiwwLDAsMS02LDZIODhhNiw2LDAsMCwxLTYtNlY2Mi40OUw0NC4yNCwxMDAuMjRhNiw2LDAsMCwxLTguNDgtOC40OGw0OC00OGE2LDYsMCwwLDEsOC40OCwwbDQ4LDQ4YTYsNiwwLDEsMS04LjQ4LDguNDhMOTQsNjIuNDlWMTg2SDIzMkE2LDYsMCwwLDEsMjM4LDE5MloiLz48L3N2Zz4=');}.icon-arrow-elbow-right-up{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjAuMjQsMTAwLjI0YTYsNiwwLDAsMS04LjQ4LDBMMTc0LDYyLjQ5VjE5MmE2LDYsMCwwLDEtNiw2SDI0YTYsNiwwLDAsMSwwLTEySDE2MlY2Mi40OWwtMzcuNzYsMzcuNzVhNiw2LDAsMCwxLTguNDgtOC40OGw0OC00OGE2LDYsMCwwLDEsOC40OCwwbDQ4LDQ4QTYsNiwwLDAsMSwyMjAuMjQsMTAwLjI0WiIvPjwvc3ZnPg==');}.icon-x{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDQuMjQsMTk1Ljc2YTYsNiwwLDEsMS04LjQ4LDguNDhMMTI4LDEzNi40OSw2MC4yNCwyMDQuMjRhNiw2LDAsMCwxLTguNDgtOC40OEwxMTkuNTEsMTI4LDUxLjc2LDYwLjI0YTYsNiwwLDAsMSw4LjQ4LTguNDhMMTI4LDExOS41MWw2Ny43Ni02Ny43NWE2LDYsMCwwLDEsOC40OCw4LjQ4TDEzNi40OSwxMjhaIi8+PC9zdmc+');}.icon-floppy-disk{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTcuOSw3My40MiwxODIuNTgsMzguMWExMy45LDEzLjksMCwwLDAtOS44OS00LjFINDhBMTQsMTQsMCwwLDAsMzQsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFY4My4zMUExMy45LDEzLjksMCwwLDAsMjE3LjksNzMuNDJaTTE3MCwyMTBIODZWMTUyYTIsMiwwLDAsMSwyLTJoODBhMiwyLDAsMCwxLDIsMlptNDAtMmEyLDIsMCwwLDEtMiwySDE4MlYxNTJhMTQsMTQsMCwwLDAtMTQtMTRIODhhMTQsMTQsMCwwLDAtMTQsMTR2NThINDhhMiwyLDAsMCwxLTItMlY0OGEyLDIsMCwwLDEsMi0ySDE3Mi42OWEyLDIsMCwwLDEsMS40MS41OEwyMDkuNDIsODEuOWEyLDIsMCwwLDEsLjU4LDEuNDFaTTE1OCw3MmE2LDYsMCwwLDEtNiw2SDk2YTYsNiwwLDAsMSwwLTEyaDU2QTYsNiwwLDAsMSwxNTgsNzJaIi8+PC9zdmc+');}.icon-x-circle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjQuMjQsMTAwLjI0LDEzNi40OCwxMjhsMjcuNzYsMjcuNzZhNiw2LDAsMSwxLTguNDgsOC40OEwxMjgsMTM2LjQ4bC0yNy43NiwyNy43NmE2LDYsMCwwLDEtOC40OC04LjQ4TDExOS41MiwxMjgsOTEuNzYsMTAwLjI0YTYsNiwwLDAsMSw4LjQ4LTguNDhMMTI4LDExOS41MmwyNy43Ni0yNy43NmE2LDYsMCwwLDEsOC40OCw4LjQ4Wk0yMzAsMTI4QTEwMiwxMDIsMCwxLDEsMTI4LDI2LDEwMi4xMiwxMDIuMTIsMCwwLDEsMjMwLDEyOFptLTEyLDBhOTAsOTAsMCwxLDAtOTAsOTBBOTAuMSw5MC4xLDAsMCwwLDIxOCwxMjhaIi8+PC9zdmc+');}.icon-minus-square{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMzRINDhBMTQsMTQsMCwwLDAsMzQsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFY0OEExNCwxNCwwLDAsMCwyMDgsMzRabTIsMTc0YTIsMiwwLDAsMS0yLDJINDhhMiwyLDAsMCwxLTItMlY0OGEyLDIsMCwwLDEsMi0ySDIwOGEyLDIsMCwwLDEsMiwyWm0tMzYtODBhNiw2LDAsMCwxLTYsNkg4OGE2LDYsMCwwLDEsMC0xMmg4MEE2LDYsMCwwLDEsMTc0LDEyOFoiLz48L3N2Zz4=');}.icon-pencil-simple{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjUuOSw3NC43OCwxODEuMjEsMzAuMDlhMTQsMTQsMCwwLDAtMTkuOCwwTDM4LjEsMTUzLjQxYTEzLjk0LDEzLjk0LDAsMCwwLTQuMSw5LjlWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDkyLjY5YTEzLjk0LDEzLjk0LDAsMCwwLDkuOS00LjFMMjI1LjksOTQuNThhMTQsMTQsMCwwLDAsMC0xOS44Wk05NC4xLDIwOS40MWEyLDIsMCwwLDEtMS40MS41OUg0OGEyLDIsMCwwLDEtMi0yVjE2My4zMWEyLDIsMCwwLDEsLjU5LTEuNDFMMTM2LDcyLjQ4LDE4My41MSwxMjBaTTIxNy40MSw4Ni4xLDE5MiwxMTEuNTEsMTQ0LjQ5LDY0LDE2OS45LDM4LjU4YTIsMiwwLDAsMSwyLjgzLDBsNDQuNjgsNDQuNjlhMiwyLDAsMCwxLDAsMi44M1oiLz48L3N2Zz4=');}.icon-dots-six-vertical{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMDIsNjBBMTAsMTAsMCwxLDEsOTIsNTAsMTAsMTAsMCwwLDEsMTAyLDYwWm02MiwxMGExMCwxMCwwLDEsMC0xMC0xMEExMCwxMCwwLDAsMCwxNjQsNzBaTTkyLDExOGExMCwxMCwwLDEsMCwxMCwxMEExMCwxMCwwLDAsMCw5MiwxMThabTcyLDBhMTAsMTAsMCwxLDAsMTAsMTBBMTAsMTAsMCwwLDAsMTY0LDExOFpNOTIsMTg2YTEwLDEwLDAsMSwwLDEwLDEwQTEwLDEwLDAsMCwwLDkyLDE4NlptNzIsMGExMCwxMCwwLDEsMCwxMCwxMEExMCwxMCwwLDAsMCwxNjQsMTg2WiIvPjwvc3ZnPg==');}.icon-list{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsMTI4YTYsNiwwLDAsMS02LDZINDBhNiw2LDAsMCwxLDAtMTJIMjE2QTYsNiwwLDAsMSwyMjIsMTI4Wk00MCw3MEgyMTZhNiw2LDAsMCwwLDAtMTJINDBhNiw2LDAsMCwwLDAsMTJaTTIxNiwxODZINDBhNiw2LDAsMCwwLDAsMTJIMjE2YTYsNiwwLDAsMCwwLTEyWiIvPjwvc3ZnPg==');}.icon-loading{--icon:url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyAgICB3aWR0aD0iMzIiICAgIGhlaWdodD0iMzIiICAgIHZpZXdCb3g9IjAgMCAzMiAzMiIgICAgdmVyc2lvbj0iMS4xIiAgICB4bWw6c3BhY2U9InByZXNlcnZlIiAgICBzdHlsZT0iY2xpcC1ydWxlOmV2ZW5vZGQ7ZmlsbC1ydWxlOmV2ZW5vZGQ7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7c3Ryb2tlLWxpbmVqb2luOnJvdW5kO3N0cm9rZS1taXRlcmxpbWl0OjEuNSIgICAgaWQ9InN2ZzEwIiAgICB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxkZWZzICAgIGlkPSJkZWZzMTAiIC8+PHBhdGggICAgaWQ9InBhdGgxMSIgICAgc3R5bGU9ImJhc2VsaW5lLXNoaWZ0OmJhc2VsaW5lO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7dmVjdG9yLWVmZmVjdDpub25lO2ZpbGw6IzIyMjIyMjtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlO3N0b3AtY29sb3I6IzAwMDAwMDtzdG9wLW9wYWNpdHk6MSIgICAgZD0ibSAxNi42MjEwOTQsMS4xNDI1NzgxIGMgLTguMjY2MzIzMiwwIC0xNi4yMjA4NjczOCw2LjQ0MjgwOTUgLTE1LjU4NTkzNzgsMTQuNjg1NTQ2OSAwLjYwMTM0NTUsNy44MDczMDggNy40MzQxMjY0LDE0LjEyNjk4IDE0LjkzMzU5MzgsMTQuOTQzMzU5IDguODM5ODQ1LDAuOTYyMjgzIDE1LjUwNTQ2OSwtNi4zNzY5MTkgMTUuMDA1ODU5LC0xNC45ODYzMjggQyAzMC40OTU5LDcuNTM2MjY4NCAyNC44ODMzOTcsMS4xNDI1NzgxIDE2LjYyMTA5NCwxLjE0MjU3ODEgWiBtIDAsMC42NTAzOTA3IEMgMjYuNDg4Nzg2LDEuODAzODY0NSAyOS43MTQ1MTgsOS41OTM1ODMzIDMwLjMwMjczNCwxNS44MDQ2ODggMzEuMTQxOTgyLDI0LjY2NjM2NSAyMi4xNjA0NTksMzEuMTY4MDc3IDE2LjAzOTA2MiwzMC4xMjUgOC44OTUxMzI3LDI4LjkwNzY4MSAyLjI2MTMxNDIsMjMuMjc5Mzc2IDEuNjgzNTkzOCwxNS43NzkyOTcgMS4wNzY5MzM4LDcuOTAzMjc1NCA4LjcyMjU0NTEsMS43ODQyNjk5IDE2LjYyMTA5NCwxLjc5Mjk2ODggWiBtIC0wLjA2NDQ1LDEuMjE4NzUgYyAtMy42MTAwODMsMCAtNy4xNTQ3OTk1LDEuNDAxMDY4NyAtOS43MzA0NjkxLDMuNzAzMTI1IEMgNC4yNTA1MDIzLDkuMDE2OTAwMiAyLjY0MjAzNzIsMTIuMjI2Mjk1IDIuOTE5OTIxOSwxNS44MzM5ODQgMy40NDY5MzUsMjIuNjc1NzEyIDkuNDI4OTY0OSwyOC4xOTg5ODUgMTUuOTk4MDQ3LDI4LjkxNDA2MiAyMy43MTQyNTYsMjkuNzU0MDIzIDI5LjUzMTYwMywyMy4zMzE3IDI5LjA5NTcwMywxNS44MjAzMTIgMjguNjc3OTQ4LDguNjIxMzk1MyAyMy43NzY2ODYsMy4wMTE3MTg4IDE2LjU1NjY0MSwzLjAxMTcxODggWiBtIDAsMC4xOTUzMTI0IGMgNy4xMTkxMzQsMCAxMS45MzI3MSw1LjUwODEzNzMgMTIuMzQ1NzAzLDEyLjYyNDk5OTggQyAyOS4zMzIwNjIsMjMuMjM2ODk2IDIzLjYxODk1OCwyOS41NDU5OTggMTYuMDE5NTMxLDI4LjcxODc1IDkuNTQ1NDMyMSwyOC4wMTQwMTIgMy42MzQxNjM3LDIyLjU1NTE0MyAzLjExNTIzNDQsMTUuODE4MzU5IDIuODQyNDU2MywxMi4yNzY5NjcgNC40MTg0MTA5LDkuMTI4MzE2OSA2Ljk1NzAzMTIsNi44NTkzNzUgOS40OTU2NTE2LDQuNTkwNDMzMSAxMi45OTcwOTMsMy4yMDcwMzEyIDE2LjU1NjY0MSwzLjIwNzAzMTIgWiBtIC0wLjA3MDMxLDEuNDE2MDE1NyBjIC0zLjE2MTk3MywwIC02LjI2MzUwOSwxLjIyNTgxMzkgLTguNTE5NTMxMSwzLjI0MjE4NzUgQyA1LjcxMDc2OTEsOS44ODE2MDggNC4zMDE0NTQyLDEyLjY5NDU4OSA0LjU0NDkyMTksMTUuODU1NDY5IDUuMDA2NTYyNCwyMS44NDg1NTQgMTAuMjQ0MTc4LDI2LjY4NjE1OSAxNS45OTgwNDcsMjcuMzEyNSAyMi43NTcwMTMsMjguMDQ4MjYxIDI3Ljg1NDQ1MSwyMi40MjA5MzYgMjcuNDcyNjU2LDE1Ljg0MTc5NyAyNy4xMDY4MjQsOS41Mzc2MDI1IDIyLjgxMDE2LDQuNjIzMDQ2OSAxNi40ODYzMjgsNC42MjMwNDY5IFogbSAwLDAuMTk1MzEyNSBjIDYuMjIyOTIsMCAxMC40Mjk5NDYsNC44MTMwMTM4IDEwLjc5MTAxNiwxMS4wMzUxNTY2IDAuMzc1NjEzLDYuNDcyNjE1IC00LjYxNzU4NCwxMS45ODY3MiAtMTEuMjU5NzY2LDExLjI2MzY3MiBDIDEwLjM1ODY4NSwyNi41MDExODYgNS4xOTE4MzgxLDIxLjcyNzk4NSA0LjczODI4MTIsMTUuODM5ODQ0IDQuNDk5OTIwMSwxMi43NDUyNjIgNS44NzY3MzE1LDkuOTk0OTc3OCA4LjA5NTcwMzEsOC4wMTE3MTg4IDEwLjMxNDY3NSw2LjAyODQ1OTUgMTMuMzc0ODksNC44MTgzNTk0IDE2LjQ4NjMyOCw0LjgxODM1OTQgWiBtIC0wLjA2ODM2LDEuNDE2MDE1NiBjIC0yLjcxMzg3NywwIC01LjM3NjExOCwxLjA1MjUxNjQgLTcuMzEyNTAwMiwyLjc4MzIwMzEgLTEuOTM2MzgyOCwxLjczMDY4NjkgLTMuMTQ2NTUxNyw0LjE0NTMxMTkgLTIuOTM3NSw2Ljg1OTM3NDkgMC4zOTYyNjk5LDUuMTQ0NDMgNC44ODk0NDQyLDkuMjk0NDI5IDkuODI4MTI1Miw5LjgzMjAzMSA1LjgwMTc0OSwwLjYzMTU2MiAxMC4xNzkyNTcsLTQuMTk4ODI4IDkuODUxNTYyLC05Ljg0NTcwMyBDIDI1LjUzMzc1LDEwLjQ1MzgyMiAyMS44NDU2MTYsNi4yMzQzNzUgMTYuNDE3OTc0LDYuMjM0Mzc1IFogbSAwLDAuMTk1MzEyNSBjIDUuMzI2NzMsMCA4LjkyNTIyNiw0LjExNzkwNTUgOS4yMzQzNzUsOS40NDUzMTI1IDAuMzIxNTEzLDUuNTQwMzUxIC0zLjk0OTgwMSwxMC4yNTk0NzQgLTkuNjM0NzY2LDkuNjQwNjI1IEMgMTEuMTczODc1LDI0Ljk4ODM2MiA2Ljc0OTUxNDMsMjAuOTAwODE0IDYuMzYxMzI4MSwxNS44NjEzMjggNi4xNTczODMxLDEzLjIxMzU2MyA3LjMzNTA0MzEsMTAuODU5NjgyIDkuMjM0Mzc1LDkuMTYyMTA5NCAxMS4xMzM3MDcsNy40NjQ1MzcyIDEzLjc1NDYyOCw2LjQyOTY4NzUgMTYuNDE3OTY5LDYuNDI5Njg3NSBaIG0gLTAuMDY4MzYsMS40MTYwMTU2IGMgLTIuMjY1Nzc1LDAgLTQuNDg4NzI5LDAuODc5MjE5NiAtNi4xMDU0NjgsMi4zMjQyMTg5IC0xLjYxNjc0MDgsMS40NDQ5OTkgLTIuNjI3NzYwNywzLjQ2MTI2OSAtMi40NTMxMjU0LDUuNzI4NTE2IDAuMzMwODk4Niw0LjI5NTc2OCA0LjA4MTU5NjQsNy43NjAxMiA4LjIwNTA3ODQsOC4yMDg5ODQgNC44NDQ1MjUsMC41MjczNiA4LjUwMDE1NiwtMy41MDYwOTcgOC4yMjY1NjIsLTguMjIwNzAzIEMgMjMuOTYwNjcyLDExLjM3MTk5NiAyMC44ODEwNiw3Ljg0NTcwMzEgMTYuMzQ5NjE0LDcuODQ1NzAzMSBaIG0gMCwwLjE5NTMxMjUgYyA0LjQzMDUzNCwwIDcuNDIyNDYxLDMuNDIyNzk5NCA3LjY3OTY4OCw3Ljg1NTQ2ODQgMC4yNjc0MTIsNC42MDgwODIgLTMuMjgzOTc4LDguNTMyMjI2IC04LjAxMTcxOSw4LjAxNzU3OCBDIDExLjk4OTA3NSwyMy40NzU1MzggOC4zMDcxODk5LDIwLjA3NTU5MyA3Ljk4NDM3NSwxNS44ODQ3NjYgNy44MTQ4NDYzLDEzLjY4MzgxOSA4Ljc5NTMxMDUsMTEuNzI2MzM4IDEwLjM3NSwxMC4zMTQ0NTMgMTEuOTU0Njg5LDguOTAyNTY4OSAxNC4xMzQzNyw4LjA0MTAxNTYgMTYuMzQ5NjA5LDguMDQxMDE1NiBaIG0gLTAuMDY4MzYsMS40MTYwMTU2IGMgLTEuODE3NjcyLDAgLTMuNjAxMzQyLDAuNzAzOTY4OCAtNC44OTg0MzgsMS44NjMyODA4IC0xLjI5NzA5NSwxLjE1OTMxIC0yLjEwODk2ODMsMi43NzkxODUgLTEuOTY4NzQ5NSw0LjU5OTYxIDAuMjY1NTI2OSwzLjQ0NzExMSAzLjI3Mzc1MDUsNi4yMjU4MTMgNi41ODIwMzE1LDYuNTg1OTM3IDMuODg3Mjk1LDAuNDIzMTYgNi44MjMwMDgsLTIuODE1MzE4IDYuNjAzNTE1LC02LjU5NzY1NiBDIDIyLjM4OTU0MSwxMi4yODgyMjIgMTkuOTE2NDk1LDkuNDU3MDMxMSAxNi4yODEyNSw5LjQ1NzAzMTIgWiBtIDAsMC4xOTUzMTI2IGMgMy41MzQzMzMsMCA1LjkxNzc0MiwyLjcyNzY5NjIgNi4xMjMwNDcsNi4yNjU2MjUyIDAuMjEzMzExLDMuNjc1ODE0IC0yLjYxNjIwOCw2LjgwMzAyNSAtNi4zODY3MTksNi4zOTI1NzggLTMuMjEzMjk4LC0wLjM0OTc4NSAtNi4xNTA3NTk3LC0zLjA2MjEzIC02LjQwODIwMywtNi40MDQyOTcgLTAuMTM1MTEyMiwtMS43NTQxMjcgMC42NDQyNTIsLTMuMzEzMjU3IDEuOTA0Mjk3LC00LjQzOTQ1MyAxLjI2MDA0NSwtMS4xMjYxOTYgMy4wMDA0NDEsLTEuODE0NDUzMyA0Ljc2NzU3OCwtMS44MTQ0NTMyIHogbSAtMC4wNzAzMSwxLjQxNjAxNTIgYyAtMS4zNjk1NzIsMCAtMi43MTIsMC41MzA2NzUgLTMuNjg5NDU0LDEuNDA0Mjk3IC0wLjk3NzQ1MywwLjg3MzYyMiAtMS41OTAxNzcsMi4wOTUxNDUgLTEuNDg0Mzc1LDMuNDY4NzUgMC4yMDAxNTYsMi41OTg0NTIgMi40NjU5LDQuNjg5NTUxIDQuOTU4OTg1LDQuOTYwOTM4IDIuOTMwMDcsMC4zMTg5NTggNS4xNDM5MDgsLTIuMTIyNTg3IDQuOTc4NTE1LC00Ljk3MjY1NiAtMC4xNTgxNDUsLTIuNzI1MjQ0IC0yLjAyNDYyMiwtNC44NjEzMjkgLTQuNzYzNjcxLC00Ljg2MTMyOSB6IG0gMCwwLjE5NTMxMyBjIDIuNjM4MTM1LDAgNC40MTQ5NzUsMi4wMzQ1NDQgNC41NjgzNTksNC42Nzc3MzQgMC4xNTkyMTEsMi43NDM1NDYgLTEuOTUwMzg2LDUuMDczODI0IC00Ljc2MzY3Miw0Ljc2NzU3OCAtMi4zOTgxMDIsLTAuMjYxMDQ3IC00LjU5MTEzMSwtMi4yODc3NDEgLTQuNzgzMjAzLC00Ljc4MTI1IC0wLjEwMDY5NiwtMS4zMDczMDggMC40Nzk1MTksLTIuNDcwMDM5IDEuNDE5OTIyLC0zLjMxMDU0NiAwLjk0MDQwMywtMC44NDA1MDggMi4yMzk1NTcsLTEuMzUzNTE2IDMuNTU4NTk0LC0xLjM1MzUxNiB6IG0gLTAuMDY4MzYsMS40MTYwMTYgYyAtMC45MjE0NzIsMCAtMS44MjI2NTcsMC4zNTU0MjUgLTIuNDgwNDY5LDAuOTQzMzU5IC0wLjY1NzgxMSwwLjU4NzkzNCAtMS4wNzMzMzksMS40MTUwMSAtMS4wMDE5NTMsMi4zNDE3OTcgMC4xMzQ3ODUsMS43NDk3OTIgMS42NTYwOTUsMy4xNTMyOTEgMy4zMzM5ODUsMy4zMzU5MzcgMS45NzI4NDYsMC4yMTQ3NTkgMy40NjY3NiwtMS40MzE4MDkgMy4zNTU0NjgsLTMuMzQ5NjA5IC0wLjEwNjIyNCwtMS44MzA1MDMgLTEuMzY0MTc3LC0zLjI3MTQ4NyAtMy4yMDcwMzEsLTMuMjcxNDg0IHogbSAwLDAuMTk1MzEyIGMgMS43NDE5NDIsMCAyLjkxMjIwOSwxLjMzOTQ0IDMuMDEzNjcyLDMuMDg3ODkxIDAuMTA1MTEsMS44MTEyNzYgLTEuMjg0NTYyLDMuMzQ2NTc3IC0zLjE0MDYyNSwzLjE0NDUzMSAtMS41ODI5MDcsLTAuMTcyMzA3IC0zLjAzMzQ1NSwtMS41MTMzNTUgLTMuMTYwMTU2LC0zLjE1ODIwMyAtMC4wNjYyOCwtMC44NjA0OSAwLjMxNDc4NSwtMS42MjQ4NjggMC45MzU1NDcsLTIuMTc5Njg4IDAuNjIwNzQ5LC0wLjU1NDgxOSAxLjQ4MDYyLC0wLjg5NDUzMSAyLjM1MTU1NiwtMC44OTQ1MzEgeiBtIC0wLjA2ODM2LDEuNDE2MDE2IGMgLTAuNDczMzY5LDAgLTAuOTM1MjcxLDAuMTgyMTI5IC0xLjI3MzQzOCwwLjQ4NDM3NSAtMC4zMzgxNjcsMC4zMDIyNDYgLTAuNTU0NTQ2LDAuNzMwOTY5IC0wLjUxNzU3OCwxLjIxMDkzNyAwLjA2OTQxLDAuOTAxMTMzIDAuODQ4MjQ5LDEuNjE4OTgxIDEuNzEwOTM4LDEuNzEyODkxIDEuMDE1NjE2LDAuMTEwNTU3IDEuNzg5NjE0LC0wLjc0MTAzMSAxLjczMjQyMSwtMS43MjY1NjMgLTAuMDU0MywtMC45MzU3NjYgLTAuNzA1NjkxLC0xLjY4MTY0IC0xLjY1MjM0MywtMS42ODE2NCB6IG0gMCwwLjE5NTMxMiBjIDAuODQ1NzQsMCAxLjQwNzQ5LDAuNjQ0MzMzIDEuNDU3MDMxLDEuNDk4MDQ3IDAuMDUxMDEsMC44NzkwMDggLTAuNjE2NzkzLDEuNjE5MzI5IC0xLjUxNTYyNSwxLjUyMTQ4NCAtMC43Njc3MDYsLTAuMDgzNTcgLTEuNDc1NzgsLTAuNzM4OTY3IC0xLjUzNzEwOSwtMS41MzUxNTYgLTAuMDMxODYsLTAuNDEzNjcxIDAuMTUwMDU1LC0wLjc3OTY5NyAwLjQ1MTE3MiwtMS4wNDg4MjggMC4zMDExMTYsLTAuMjY5MTMxIDAuNzIxNjk4LC0wLjQzNTU0NyAxLjE0NDUzMSwtMC40MzU1NDcgeiIgLz48L3N2Zz4=');}.icon-magnifying-glass{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjguMjQsMjE5Ljc2bC01MS4zOC01MS4zOGE4Ni4xNSw4Ni4xNSwwLDEsMC04LjQ4LDguNDhsNTEuMzgsNTEuMzhhNiw2LDAsMCwwLDguNDgtOC40OFpNMzgsMTEyYTc0LDc0LDAsMSwxLDc0LDc0QTc0LjA5LDc0LjA5LDAsMCwxLDM4LDExMloiLz48L3N2Zz4=');}.icon-infinity{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsMTI4YTU0LDU0LDAsMCwxLTkyLjE4LDM4LjE4LDMuMDcsMy4wNywwLDAsMS0uMjUtLjI2bC02MC02Ny43NGE0Miw0MiwwLDEsMCwwLDU5LjY0bDguNTctOS42N2E2LDYsMCwxLDEsOSw4bC04LjY5LDkuODFhMy4wNywzLjA3LDAsMCwxLS4yNS4yNiw1NCw1NCwwLDEsMSwwLTc2LjM2LDMuMDcsMy4wNywwLDAsMSwuMjUuMjZsNjAsNjcuNzRhNDIsNDIsMCwxLDAsMC01OS42NGwtOC41Nyw5LjY3YTYsNiwwLDEsMS05LThsOC42OS05LjgxYTMuMDcsMy4wNywwLDAsMSwuMjUtLjI2QTU0LDU0LDAsMCwxLDI0NiwxMjhaIi8+PC9zdmc+');}.icon-arrow-counter-clockwise{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsMTI4YTk0LDk0LDAsMCwxLTkyLjc0LDk0SDEyOGE5My40Myw5My40MywwLDAsMS02NC41LTI1LjY1LDYsNiwwLDEsMSw4LjI0LTguNzJBODIsODIsMCwxLDAsNzAsNzBsLS4xOS4xOUwzOS40NCw5OEg3MmE2LDYsMCwwLDEsMCwxMkgyNGE2LDYsMCwwLDEtNi02VjU2YTYsNiwwLDAsMSwxMiwwVjkwLjM0TDYxLjYzLDYxLjRBOTQsOTQsMCwwLDEsMjIyLDEyOFoiLz48L3N2Zz4=');}.icon-clock{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjgsMjZBMTAyLDEwMiwwLDEsMCwyMzAsMTI4LDEwMi4xMiwxMDIuMTIsMCwwLDAsMTI4LDI2Wm0wLDE5MmE5MCw5MCwwLDEsMSw5MC05MEE5MC4xLDkwLjEsMCwwLDEsMTI4LDIxOFptNjItOTBhNiw2LDAsMCwxLTYsNkgxMjhhNiw2LDAsMCwxLTYtNlY3MmE2LDYsMCwwLDEsMTIsMHY1MGg1MEE2LDYsMCwwLDEsMTkwLDEyOFoiLz48L3N2Zz4=');}.icon-house{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTcuOSwxMTAuMWwtODAtODBhMTQsMTQsMCwwLDAtMTkuOCwwbC04MCw4MEExMy45MiwxMy45MiwwLDAsMCwzNCwxMjB2OTZhNiw2LDAsMCwwLDYsNmg2NGE2LDYsMCwwLDAsNi02VjE1OGgzNnY1OGE2LDYsMCwwLDAsNiw2aDY0YTYsNiwwLDAsMCw2LTZWMTIwQTEzLjkyLDEzLjkyLDAsMCwwLDIxNy45LDExMC4xWk0yMTAsMjEwSDE1OFYxNTJhNiw2LDAsMCwwLTYtNkgxMDRhNiw2LDAsMCwwLTYsNnY1OEg0NlYxMjBhMiwyLDAsMCwxLC41OC0xLjQybDgwLTgwYTIsMiwwLDAsMSwyLjg0LDBsODAsODBBMiwyLDAsMCwxLDIxMCwxMjBaIi8+PC9zdmc+');}.icon-logo{--icon:url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyAgICB3aWR0aD0iMzIiICAgIGhlaWdodD0iMzIiICAgIHZpZXdCb3g9IjAgMCAzMiAzMiIgICAgdmVyc2lvbj0iMS4xIiAgICB4bWw6c3BhY2U9InByZXNlcnZlIiAgICBzdHlsZT0iY2xpcC1ydWxlOmV2ZW5vZGQ7ZmlsbC1ydWxlOmV2ZW5vZGQ7c3Ryb2tlLWxpbmVjYXA6c3F1YXJlO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDoxLjUiICAgIGlkPSJzdmcxNCIgICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiAgICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcyAgICBpZD0iZGVmczE0IiAvPjxwYXRoICAgIGQ9Ik0gMTYuNTgwMDc4LDIuMTMyODEyNSBDIDguODY0ODQ0OSwyLjEzMjgxMjUgMS40NDA2MDIxLDguMTQ2NjIxOCAyLjAzMzIwMzEsMTUuODM5ODQ0IDIuNTk0NDU4OCwyMy4xMjY2NjYgOC45NzEyMDEyLDI5LjAyNTE1NSAxNS45NzA3MDMsMjkuNzg3MTA5IDI0LjIyMTIyNCwzMC42ODUyNCAzMC40NDA5MTEsMjMuODM0Mjc3IDI5Ljk3NDYwOSwxNS43OTg4MjggMjkuNTI3ODEzLDguMDk5ODY1NSAyNC4yOTE1NiwyLjEzMjgxMjUgMTYuNTgwMDc4LDIuMTMyODEyNSBaIG0gMCwwLjYwNzQyMTkgYyAwLjAxMDQ2LDAgMC4wMjA4MywwIDAuMDMxMjUsMCBWIDI5LjIzMjQyMiBjIC0wLjE5MDMyMywtMC4wMTIxOCAtMC4zODE1MjEsLTAuMDI3ODMgLTAuNTc0MjE5LC0wLjA0ODgzIEMgOS4zMTMwNDUzLDI4LjQ1MTYxNSAzLjE3Nzg3NzUsMjIuNzkzMDQ0IDIuNjM4NjcxOSwxNS43OTI5NjkgMi4wNzI0NTYsOC40NDIwMTUzIDkuMjA4MTAwOCwyLjc0MDIzNDQgMTYuNTgwMDc4LDIuNzQwMjM0NCBaIE0gMTYuMDkxNzk3LDMuODg0NzY1NiAxNiwzLjg4ODY3MTkgQyAxMi43MjU0NTQsNC4wMTgzNDg5IDkuNTUyMzM3OSw1LjM2NDY4MzggNy4yNTU4NTk0LDcuNSA0Ljk1OTM4MDksOS42MzUzMTYyIDMuNTQwMjcwMywxMi41NjQ5NzIgMy43OTI5Njg4LDE1Ljg0NTcwMyA0LjI4NDc3MzksMjIuMjMwMDQ1IDkuODY0NDgxMiwyNy4zODM2MDYgMTUuOTk0MTQxLDI4LjA1MjczNCBsIDAuMDg5ODQsMC4wMDk4IDAuMDIxNDgsLTAuMTgxNjQxIC0wLjA5MTgsLTAuMDA5OCBDIDkuOTcyNjc0OSwyNy4yMTE2NDQgNC40NTg4NjkxLDIyLjExNjQ2OCAzLjk3NDYwOTQsMTUuODMwMDc4IDMuNzI2NTU1OSwxMi42MDk2NTEgNS4xMTU4MDg0LDkuNzM5MDQzNyA3LjM3ODkwNjIsNy42MzQ3NjU2IDkuNjQyMDA0MSw1LjUzMDQ4NzUgMTIuNzc4NTM5LDQuMTk4MTk2OCAxNi4wMDc4MTIsNC4wNzAzMTI1IGwgMC4wOTE4LC0wLjAwMzkxIHogbSAwLDEuNTAxOTUzMSBMIDE2LDUuMzkwNjI1IEMgMTMuMTMxOTQ5LDUuNTA0MjA0NyAxMC4zNTMyOTgsNi42ODQzNDE1IDguMzQxNzk2OSw4LjU1NDY4NzUgNi4zMzAyOTYyLDEwLjQyNTAzMyA1LjA4NzE5MjksMTIuOTkwODE5IDUuMzA4NTkzNywxNS44NjUyMzQgNS43MzkzOTQsMjEuNDU3NjY5IDEwLjYyNTE2MSwyNS45NzA1NDcgMTUuOTk0MTQxLDI2LjU1NjY0MSBsIDAuMDg5ODQsMC4wMDk4IDAuMDIxNDgsLTAuMTc5Njg3IC0wLjA5MTgsLTAuMDA5OCBDIDEwLjczMzM1NCwyNS44MDA1MzMgNS45MTM0ODkyLDIxLjM0NjA0NiA1LjQ5MDIzNDQsMTUuODUxNTYzIDUuMjczNDc4NCwxMy4wMzc0NTEgNi40ODY3MjM3LDEwLjUyNjgwOCA4LjQ2NDg0MzgsOC42ODc1IDEwLjQ0Mjk2NCw2Ljg0ODE5MjIgMTMuMTg1MDMyLDUuNjg0MDUyNiAxNi4wMDc4MTIsNS41NzIyNjU2IGwgMC4wOTE4LC0wLjAwMzkxIHogbSAwLDEuNTAzOTA2MyBMIDE2LDYuODkyNTc4MSBjIC0yLjQ2MTU3NywwLjA5NzQ4MyAtNC44NDU3MjgsMS4xMTE0MTc0IC02LjU3MjI2NTYsMi43MTY3OTY5IC0xLjcyNjUzOCwxLjYwNTM4IC0yLjc5NTU3NDEsMy44MDcyODIgLTIuNjA1NDY4OCw2LjI3NTM5MSAwLjM2OTc5ODYsNC44MDA1NCA0LjU2MzUzMzQsOC42NzQ2NzQgOS4xNzE4NzU0LDkuMTc3NzM0IGwgMC4wODk4NCwwLjAwOTggMC4wMjE0OCwtMC4xODE2NDEgLTAuMDkxOCwtMC4wMDk4IEMgMTEuNDk0MDM3LDI0LjM4NzQ4MSA3LjM2NjE1NTcsMjAuNTczNjM0IDcuMDAzOTA2MiwxNS44NzEwOTQgNi44MTg0NDgxLDEzLjQ2MzMyIDcuODU3NjQwNSwxMS4zMTY1MTMgOS41NTA3ODEzLDkuNzQyMTg3NSAxMS4yNDM5MjIsOC4xNjc4NjE4IDEzLjU5MTUyOSw3LjE3MTg2MDggMTYuMDA3ODEyLDcuMDc2MTcxOSBsIDAuMDkxOCwtMC4wMDM5MSB6IG0gMCwxLjUwMTk1MzEgTCAxNiw4LjM5NjQ4NDQgYyAtMi4wNTUwNzMsMC4wODEzODQgLTQuMDQ0Nzc1LDAuOTI1MjMzNCAtNS40ODYzMjgsMi4yNjU2MjQ2IC0xLjQ0MTU1MzUsMS4zNDAzOTMgLTIuMzM0NTg4MSwzLjE4MjM3OSAtMi4xNzU3ODE0LDUuMjQ0MTQxIDAuMzA4NzkyLDQuMDA4NTc5IDMuODA4NjA1NCw3LjI0MDEzNiA3LjY1NjI1MDQsNy42NjAxNTYgbCAwLjA4OTg0LDAuMDA5OCAwLjAwNzgsLTAuMDY4MzYgdiAtMC4wMDIgbCAwLjAwMiwtMC4wMDk4IGMgOS40OWUtNCwtMC4wMDM0IDAuMDAzNSwtMC4wMDYyIDAuMDAzOSwtMC4wMDk4IDYuNDJlLTQsLTAuMDA2NyA4LjAyZS00LC0wLjAxMzAxIDAsLTAuMDE5NTMgbCAwLjAwNzgsLTAuMDcyMjcgLTAuMDkxOCwtMC4wMDk4IEMgMTIuMjU0NjY3LDIyLjk3NDQyMiA4LjgyMDc3OTYsMTkuODAzMjMxIDguNTE5NTMxMywxNS44OTI1NzggOC4zNjUzNjgyLDEzLjg5MTEwNSA5LjIyODUzNzMsMTIuMTA2MjM3IDEwLjYzNjcxOSwxMC43OTY4NzUgMTIuMDQ0OSw5LjQ4NzUxMyAxMy45OTc5OTksOC42NTc3MTcgMTYuMDA3ODEyLDguNTc4MTI1IGwgMC4wOTE4LC0wLjAwMzkxIHogbSAwLDEuNTAxOTUzMiBMIDE2LDkuODk4NDM3NSBjIC0xLjY0ODU4OCwwLjA2NTI4NyAtMy4yNDU3NjEsMC43NDI5Mzg1IC00LjQwMjM0NCwxLjgxODM1OTUgLTEuMTU2NTgyLDEuMDc1NDIxIC0xLjg3MTY1MDYsMi41NTM1MzggLTEuNzQ0MTQwNCw0LjIwODk4NCAwLjI0Nzc4ODQsMy4yMTY2NjkgMy4wNTM2NDE0LDUuODA5NTAxIDYuMTQwNjI1NCw2LjE0NjQ4NSBsIDAuMDg5ODQsMC4wMDk4IDAuMDIxNDgsLTAuMTgxNjQgLTAuMDkxOCwtMC4wMDk4IGMgLTIuOTk4MzQ0LC0wLjMyNzMwOCAtNS43MzgyNzMsLTIuODU5Nzk4IC01Ljk3ODUxNiwtNS45Nzg1MTYgLTAuMTIyODY0NSwtMS41OTUxNDIgMC41NjQyOTgsLTMuMDE4MTE3IDEuNjg3NSwtNC4wNjI1IDEuMTIzMjAyLC0xLjA0NDM4MiAyLjY4MTgzOSwtMS43MDYwMzcgNC4yODUxNTYsLTEuNzY5NTMxIGwgMC4wOTE4LC0wLjAwMzkgeiBtIDAsMS41MDM5MDY3IC0wLjA5MTgsMC4wMDIgYyAtMS4yNDIwOTUsMC4wNDkxOSAtMi40NDQ4LDAuNTYwNjUyIC0zLjMxNjQwNiwxLjM3MTA5MyAtMC44NzE2MDYsMC44MTA0NDIgLTEuNDEyNjE5LDEuOTI0NzEzIC0xLjMxNjQwNiwzLjE3MzgyOSAwLjE4Njc4MywyLjQyNDczMiAyLjMwMDY0Myw0LjM3NjkxMyA0LjYyNjk1Myw0LjYzMDg1OSBsIDAuMDg5ODQsMC4wMDk4IDAuMDIxNDgsLTAuMTgxNjQgLTAuMDkxOCwtMC4wMDk4IGMgLTIuMjM3NjkyLC0wLjI0NDI3MiAtNC4yODU2MDQsLTIuMTM2MDgzIC00LjQ2NDg0NCwtNC40NjI4OSAtMC4wOTE1NywtMS4xODg4MjYgMC40MjE1MzIsLTIuMjQ3OTMzIDEuMjU5NzY2LC0zLjAyNzM0NCAwLjgzODIzNCwtMC43Nzk0MTEgMi4wMDIzODMsLTEuMjcyOTE2IDMuMTk5MjE4LC0xLjMyMDMxMyBsIDAuMDkxOCwtMC4wMDM5IHogbSAwLDEuNTAxOTUzIC0wLjA5MTgsMC4wMDM5IGMgLTAuODM1NjEzLDAuMDMzMDkgLTEuNjQzODMxLDAuMzc0NDUyIC0yLjIzMDQ2OSwwLjkxOTkyMiAtMC41ODY2MzcsMC41NDU0NyAtMC45NTE2MzYsMS4yOTk3NzggLTAuODg2NzE4LDIuMTQyNTc4IDAuMTI1NzgsMS42MzI4MjEgMS41NDU2NzMsMi45NDIzNyAzLjExMTMyOCwzLjExMzI4MSAtMC4wMDI2LC0yLjhlLTQgLTAuMDA1MywyLjg2ZS00IC0wLjAwNzgsMCAwLjAwMyw2LjAzZS00IDAuMDA2NiwwLjAwMTcgMC4wMDk4LDAuMDAyIDAuMDAzMSwzLjA4ZS00IDAuMDA2NywxLjJlLTUgMC4wMDk4LDAgbCAwLjA3ODEzLDAuMDA5OCAwLjAyMTQ4LC0wLjE4MTY0MSAtMC4wOTE4LC0wLjAwOTggYyAtMS40NzcwMTUsLTAuMTYxMjM1IC0yLjgzMDk4NCwtMS40MTIzOTYgLTIuOTQ5MjE5LC0yLjk0NzI2NiAtMC4wNjAyNywtMC43ODI0OTYgMC4yNzY4MjIsLTEuNDc5NzA5IDAuODMwMDc4LC0xLjk5NDE0MSAwLjU1MzI1NywtMC41MTQ0MzEgMS4zMjI5MzksLTAuODQxNzQ4IDIuMTEzMjgxLC0wLjg3MzA0NiBsIDAuMDkxOCwtMC4wMDM5IHogbSAwLDEuNTAxOTUzIEwgMTYsMTQuNDA2MjUgYyAtMC40MjkxMTcsMC4wMTY5OSAtMC44NDI4NzMsMC4xOTIxNjYgLTEuMTQ0NTMxLDAuNDcyNjU2IC0wLjMwMTY1OSwwLjI4MDQ5MSAtMC40OTA2NDgsMC42NzA5NTUgLTAuNDU3MDMxLDEuMTA3NDIyIDAuMDY0NzcsMC44NDA4ODYgMC43OTA3MjksMS41MTE3MzIgMS41OTU3MDMsMS41OTk2MDkgbCAwLjA4OTg0LDAuMDA5OCAwLjAyMTQ4LC0wLjE4MTY0MSAtMC4wOTE4LC0wLjAwOTggYyAtMC43MTYzNTcsLTAuMDc4MiAtMS4zNzYzNjIsLTAuNjg4NjgxIC0xLjQzMzU5NCwtMS40MzE2NDEgLTAuMDI4OTcsLTAuMzc2MTc5IDAuMTMyMTA1LC0wLjcxMTQ3NyAwLjQwMDM5MSwtMC45NjA5MzcgMC4yNjgyODYsLTAuMjQ5NDYxIDAuNjQzNDg1LC0wLjQwODYyNyAxLjAyNzM0MywtMC40MjM4MjggbCAwLjA5MTgsLTAuMDAzOSB6IiAgICBzdHlsZT0iYmFzZWxpbmUtc2hpZnQ6YmFzZWxpbmU7Y2xpcC1ydWxlOm5vbnplcm87ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTt2ZWN0b3ItZWZmZWN0Om5vbmU7ZmlsbDojMjIyMjIyO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGU7c3RvcC1jb2xvcjojMDAwMDAwO3N0b3Atb3BhY2l0eToxIiAgICBpZD0icGF0aDI3IiAvPjwvc3ZnPg==');}.icon-jakevan{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHN0eWxlPSJjbGlwLXJ1bGU6ZXZlbm9kZDtmaWxsLXJ1bGU6ZXZlbm9kZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLW1pdGVybGltaXQ6MiIgdmlld0JveD0iMCAwIDMyIDMyIj48cGF0aCBkPSJNMTcuODggMTQuNjhIMTIuOWwtLjQzLTEuNjNIOS41OGwtLjQ1IDEuNjNINi41bDIuODktOC43NGgzLjJsMi44OSA4LjY0di04LjZoMi40djMuNzhjLjEtLjIuMjItLjM4LjM1LS41Ny4xMy0uMi4yNi0uMzcuMzktLjU0bDEuODYtMi42N2g3Ljh2MS44OUgyNS40djEuMzdoMi42NXYxLjg4SDI1LjR2MS42NWgyLjg2djEuOTFoLTcuOTNsLTEuNzUtMy4zMi0uNy40MXptNS4xMy04LjU5LTIuNyAzLjc5IDIuNyA0Ljc0em0tMTEuMDUgNS4wMy0uMzgtMS40M2ExMzYuODYgMTM2Ljg2IDAgMCAwLS40LTEuNTVMMTEgNy4zOGExNy43NiAxNy43NiAwIDAgMS0uMzYgMS42bC0uMTguNzEtLjM5IDEuNDN6bS04LjU4IDYuM2E1Ljc0IDUuNzQgMCAwIDEtMS4yNC0uMTN2LTEuODNsLjQxLjA4Yy4xNS4wMy4zLjA1LjQ3LjA1LjMgMCAuNTEtLjA2LjY3LS4xN2EuOTIuOTIgMCAwIDAgLjM0LS41MmMuMDYtLjIzLjEtLjUyLjEtLjg2VjUuOThoMi40djcuODVjMCAuODgtLjEzIDEuNTctLjQgMi4xLS4yNi41Mi0uNjMuOS0xLjEgMS4xNC0uNDguMjMtMS4wMy4zNS0xLjY1LjM1WiIgc3R5bGU9ImZpbGw6Y3VycmVudENvbG9yO3N0cm9rZS13aWR0aDouMDE4NDM5MiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMS40IC42Nikgc2NhbGUoLjk2MDUwMTM0KSIvPjxwYXRoIGQ9Ik0yMi44MiAyMi4yN2gtNC4wNmwtLjM3LTEuNEgxNS45bC0uMzkgMS40aC0yLjI2bDIuNDktNy41M2gyLjc1bDIuNDkgNy40NHYtNy40MWgyLjdsMi43NyA1LjIxaC4wM2E0MS4xIDQxLjEgMCAwIDEtLjA3LTEuODJ2LTMuMzloMS44M3Y3LjVoLTIuN2wtMi43OS01LjI4aC0uMDRhMTIuODMgMTIuODMgMCAwIDEgLjA4IDEuMjZsLjAyLjY0em0tNC44Ni0zLjA3LS4zMy0xLjIzYTg5LjA3IDg5LjA3IDAgMCAwLS4zNS0xLjM0bC0uMTQtLjY1YTE1LjA0IDE1LjA0IDAgMCAxLS4zMSAxLjM3bC0uMTYuNjItLjMzIDEuMjN6bS0zLjg1LTQuNDMtMi41IDcuNUg5LjJsLTIuNS03LjVoMi4zMmwxLjA0IDMuOGExNS4wMyAxNS4wMyAwIDAgMSAuMzYgMS43NiA3LjYxIDcuNjEgMCAwIDEgLjItMS4ybC4xNC0uNTQgMS4wNi0zLjgyeiIgc3R5bGU9ImZpbGw6Y3VycmVudENvbG9yO3N0cm9rZS13aWR0aDouMDE1OTg4NCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMS40IC42Nikgc2NhbGUoLjk2MDUwMTM0KSIvPjxwYXRoIGQ9Ik0xMS45IDI0LjIxYzAgLjQtLjA3LjcyLS4yLjk5LS4xNS4yNi0uMzYuNDYtLjYzLjYtLjI4LjEzLS42Mi4yLTEuMDMuMkg5LjJ2LTMuNWguOTdjLjM4IDAgLjcuMDYuOTYuMTkuMjUuMTMuNDUuMzIuNTguNTguMTQuMjUuMi41Ny4yLjk0em0tLjI2LjAxYzAtLjMzLS4wNS0uNjEtLjE2LS44M2ExLjEgMS4xIDAgMCAwLS41MS0uNTEgMS45NSAxLjk1IDAgMCAwLS44Ny0uMTdoLS42NnYzLjA3aC42Yy41MyAwIC45My0uMTMgMS4yLS4zOS4yNy0uMjYuNC0uNjUuNC0xLjE3ek0xNC4yNyAyNmgtMS45NXYtMy41aDEuOTV2LjIyaC0xLjd2MS4zMmgxLjZ2LjIzaC0xLjZ2MS41aDEuN3ptMS4yOC0zLjVjLjI4IDAgLjUyLjAyLjcuMDhhLjguOCAwIDAgMSAuNDQuM2MuMS4xNC4xNC4zMy4xNC41N2EuOS45IDAgMCAxLS4xLjQ1Ljg3Ljg3IDAgMCAxLS4yNy4zMmMtLjEyLjA4LS4yNS4xNC0uNC4xOGwuOTggMS42aC0uM2wtLjkyLTEuNTNoLS44OVYyNmgtLjI1di0zLjV6bS0uMDMuMjFoLS41OXYxLjU1aC43MWMuMyAwIC41Mi0uMDcuNjktLjIxLjE2LS4xNC4yNC0uMzQuMjQtLjYgMC0uMjgtLjA5LS40Ny0uMjYtLjU4LS4xNy0uMS0uNDMtLjE2LS43OS0uMTZ6bTUuNTctLjIyTDIwLjEyIDI2aC0uMjVsLS43Ni0yLjY1LS4wNS0uMTYtLjA0LS4xNGExOC44IDE4LjggMCAwIDEtLjA2LS4yNCAyMC42IDIwLjYgMCAwIDEtLjExLjQ4TDE4LjA5IDI2aC0uMjVsLS45Ni0zLjVoLjI2bC42NyAyLjQ3YTI3LjM2IDI3LjM2IDAgMCAxIC4wOS4zNWwuMDQuMTcuMDMuMTUuMDMtLjE2YTQuODMgNC44MyAwIDAgMSAuMTQtLjUzbC43LTIuNDZoLjI1bC43MyAyLjQ4YTExLjk4IDExLjk4IDAgMCAxIC4xMy41M2wuMDQuMTVhMTEuMDIgMTEuMDIgMCAwIDEgLjE1LS42OGwuNjktMi40OHpNMjMuMjYgMjZoLTEuOTV2LTMuNWgxLjk1di4yMmgtMS43djEuMzJoMS42di4yM2gtMS42djEuNWgxLjd6bTEuMjgtMy41Yy4yOCAwIC41Mi4wMi43MS4wOGEuOC44IDAgMCAxIC40My4zYy4xLjE0LjE0LjMzLjE0LjU3YS45LjkgMCAwIDEtLjEuNDUuODcuODcgMCAwIDEtLjI3LjMyYy0uMTEuMDgtLjI1LjE0LS40LjE4bC45OCAxLjZoLS4zbC0uOTItMS41M2gtLjg4VjI2aC0uMjZ2LTMuNXptLS4wMi4yMWgtLjZ2MS41NWguNzJjLjI5IDAgLjUxLS4wNy42OC0uMjEuMTYtLjE0LjI0LS4zNC4yNC0uNiAwLS4yOC0uMDgtLjQ3LS4yNi0uNTgtLjE3LS4xLS40My0uMTYtLjc4LS4xNnpNMjYuNSAyNmgtLjI1di0zLjVoMS45NXYuMjJoLTEuN3YxLjQ5aDEuNnYuMjJoLTEuNnoiIHN0eWxlPSJmaWxsOmN1cnJlbnRDb2xvcjtzdHJva2Utd2lkdGg6LjAxMDEwNjgiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDEuNCAuNjYpIHNjYWxlKC45NjA1MDEzNCkiLz48L3N2Zz4=');}.icon-user-square{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMzRINDhBMTQsMTQsMCwwLDAsMzQsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFY0OEExNCwxNCwwLDAsMCwyMDgsMzRaTTk0LDEyMGEzNCwzNCwwLDEsMSwzNCwzNEEzNCwzNCwwLDAsMSw5NCwxMjBaTTY1Ljc3LDIxMGE2Ni40Myw2Ni40MywwLDAsMSwyMC43Ny0yOS4zNiw2Niw2NiwwLDAsMSw4Mi45MiwwQTY2LjQzLDY2LjQzLDAsMCwxLDE5MC4yMywyMTBaTTIxMCwyMDhhMiwyLDAsMCwxLTIsMmgtNS4xN2E3Ny44NSw3Ny44NSwwLDAsMC00OS4zOC01MS43MSw0Niw0NiwwLDEsMC01MC45LDBBNzcuODUsNzcuODUsMCwwLDAsNTMuMTcsMjEwSDQ4YTIsMiwwLDAsMS0yLTJWNDhhMiwyLDAsMCwxLDItMkgyMDhhMiwyLDAsMCwxLDIsMloiLz48L3N2Zz4=');}.icon-chat-teardrop{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMzIsMjZhOTguMTEsOTguMTEsMCwwLDAtOTgsOTh2ODRhMTQsMTQsMCwwLDAsMTQsMTRoODRhOTgsOTgsMCwwLDAsMC0xOTZabTAsMTg0SDQ4YTIsMiwwLDAsMS0yLTJWMTI0YTg2LDg2LDAsMSwxLDg2LDg2WiIvPjwvc3ZnPg==');}.icon-house-simple{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTcuOSwxMTAuMWwtODAtODBhMTQsMTQsMCwwLDAtMTkuOCwwbC04MCw4MEExMy45MiwxMy45MiwwLDAsMCwzNCwxMjB2OTZhNiw2LDAsMCwwLDYsNkgyMTZhNiw2LDAsMCwwLDYtNlYxMjBBMTMuOTIsMTMuOTIsMCwwLDAsMjE3LjksMTEwLjFaTTIxMCwyMTBINDZWMTIwYTIsMiwwLDAsMSwuNTgtMS40Mmw4MC04MGEyLDIsMCwwLDEsMi44NCwwbDgwLDgwQTIsMiwwLDAsMSwyMTAsMTIwWiIvPjwvc3ZnPg==');}.icon-caret-left{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjQuMjQsMjAzLjc2YTYsNiwwLDEsMS04LjQ4LDguNDhsLTgwLTgwYTYsNiwwLDAsMSwwLTguNDhsODAtODBhNiw2LDAsMCwxLDguNDgsOC40OEw4OC40OSwxMjhaIi8+PC9zdmc+');}.icon-chat{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTYsNTBINDBBMTQsMTQsMCwwLDAsMjYsNjRWMjI0YTEzLjg4LDEzLjg4LDAsMCwwLDguMDksMTIuNjlBMTQuMTEsMTQuMTEsMCwwLDAsNDAsMjM4YTEzLjg3LDEzLjg3LDAsMCwwLDktMy4zMWwuMDYtLjA1TDgyLjIzLDIwNkgyMTZhMTQsMTQsMCwwLDAsMTQtMTRWNjRBMTQsMTQsMCwwLDAsMjE2LDUwWm0yLDE0MmEyLDIsMCwwLDEtMiwySDgwYTYsNiwwLDAsMC0zLjkyLDEuNDZMNDEuMjYsMjI1LjUzQTIsMiwwLDAsMSwzOCwyMjRWNjRhMiwyLDAsMCwxLDItMkgyMTZhMiwyLDAsMCwxLDIsMloiLz48L3N2Zz4=');}.icon-envelope{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjQsNTBIMzJhNiw2LDAsMCwwLTYsNlYxOTJhMTQsMTQsMCwwLDAsMTQsMTRIMjE2YTE0LDE0LDAsMCwwLDE0LTE0VjU2QTYsNiwwLDAsMCwyMjQsNTBabS05Niw4NS44Nkw0Ny40Miw2MkgyMDguNThaTTEwMS42NywxMjgsMzgsMTg2LjM2VjY5LjY0Wm04Ljg4LDguMTRMMTI0LDE0OC40MmE2LDYsMCwwLDAsOC4xLDBsMTMuNC0xMi4yOEwyMDguNTgsMTk0SDQ3LjQzWk0xNTQuMzMsMTI4LDIxOCw2OS42NFYxODYuMzZaIi8+PC9zdmc+');}.icon-caret-right{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xODAuMjQsMTMyLjI0bC04MCw4MGE2LDYsMCwwLDEtOC40OC04LjQ4TDE2Ny41MSwxMjgsOTEuNzYsNTIuMjRhNiw2LDAsMCwxLDguNDgtOC40OGw4MCw4MEE2LDYsMCwwLDEsMTgwLjI0LDEzMi4yNFoiLz48L3N2Zz4=');}.icon-calendar{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMzRIMTgyVjI0YTYsNiwwLDAsMC0xMiwwVjM0SDg2VjI0YTYsNiwwLDAsMC0xMiwwVjM0SDQ4QTE0LDE0LDAsMCwwLDM0LDQ4VjIwOGExNCwxNCwwLDAsMCwxNCwxNEgyMDhhMTQsMTQsMCwwLDAsMTQtMTRWNDhBMTQsMTQsMCwwLDAsMjA4LDM0Wk00OCw0Nkg3NFY1NmE2LDYsMCwwLDAsMTIsMFY0Nmg4NFY1NmE2LDYsMCwwLDAsMTIsMFY0NmgyNmEyLDIsMCwwLDEsMiwyVjgySDQ2VjQ4QTIsMiwwLDAsMSw0OCw0NlpNMjA4LDIxMEg0OGEyLDIsMCwwLDEtMi0yVjk0SDIxMFYyMDhBMiwyLDAsMCwxLDIwOCwyMTBabS05OC05MHY2NGE2LDYsMCwwLDEtMTIsMFYxMjkuNzFsLTcuMzIsMy42NmE2LDYsMCwxLDEtNS4zNi0xMC43NGwxNi04QTYsNiwwLDAsMSwxMTAsMTIwWm01OS41NywyOS4yNUwxNDgsMTc4aDIwYTYsNiwwLDAsMSwwLDEySDEzNmE2LDYsMCwwLDEtNC44LTkuNkwxNjAsMTQyYTEwLDEwLDAsMSwwLTE2LjY1LTExQTYsNiwwLDEsMSwxMzMsMTI1YTIyLDIyLDAsMSwxLDM2LjYyLDI0LjI2WiIvPjwvc3ZnPg==');}.icon-shuffle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzYuMjQsMTc5Ljc2YTYsNiwwLDAsMSwwLDguNDhsLTI0LDI0YTYsNiwwLDAsMS04LjQ4LTguNDhMMjE3LjUyLDE5MEgyMDAuOTRhNzAuMTYsNzAuMTYsMCwwLDEtNTctMjkuMzFsLTQxLjcxLTU4LjRBNTguMTEsNTguMTEsMCwwLDAsNTUuMDYsNzhIMzJhNiw2LDAsMCwxLDAtMTJINTUuMDZhNzAuMTYsNzAuMTYsMCwwLDEsNTcsMjkuMzFsNDEuNzEsNTguNEE1OC4xMSw1OC4xMSwwLDAsMCwyMDAuOTQsMTc4aDE2LjU4bC0xMy43Ni0xMy43NmE2LDYsMCwwLDEsOC40OC04LjQ4Wm0tOTIuMDYtNzQuNDFhNS45MSw1LjkxLDAsMCwwLDMuNDgsMS4xMiw2LDYsMCwwLDAsNC44OS0yLjUxbDEuMTktMS42N0E1OC4xMSw1OC4xMSwwLDAsMSwyMDAuOTQsNzhoMTYuNThMMjAzLjc2LDkxLjc2YTYsNiwwLDEsMCw4LjQ4LDguNDhsMjQtMjRhNiw2LDAsMCwwLDAtOC40OGwtMjQtMjRhNiw2LDAsMCwwLTguNDgsOC40OEwyMTcuNTIsNjZIMjAwLjk0YTcwLjE2LDcwLjE2LDAsMCwwLTU3LDI5LjMxTDE0Mi43OCw5N0E2LDYsMCwwLDAsMTQ0LjE4LDEwNS4zNVptLTMyLjM2LDQ1LjNhNiw2LDAsMCwwLTguMzcsMS4zOWwtMS4xOSwxLjY3QTU4LjExLDU4LjExLDAsMCwxLDU1LjA2LDE3OEgzMmE2LDYsMCwwLDAsMCwxMkg1NS4wNmE3MC4xNiw3MC4xNiwwLDAsMCw1Ny0yOS4zMWwxLjE5LTEuNjdBNiw2LDAsMCwwLDExMS44MiwxNTAuNjVaIi8+PC9zdmc+');}.icon-sort-descending{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik00MiwxMjhhNiw2LDAsMCwxLDYtNmg3MmE2LDYsMCwwLDEsMCwxMkg0OEE2LDYsMCwwLDEsNDIsMTI4Wm02LTU4aDU2YTYsNiwwLDAsMCwwLTEySDQ4YTYsNiwwLDAsMCwwLDEyWk0xODQsMTg2SDQ4YTYsNiwwLDAsMCwwLDEySDE4NGE2LDYsMCwwLDAsMC0xMlpNMjI4LjI0LDgzLjc2bC00MC00MGE2LDYsMCwwLDAtOC40OCwwbC00MCw0MGE2LDYsMCwwLDAsOC40OCw4LjQ4TDE3OCw2Mi40OVYxNDRhNiw2LDAsMCwwLDEyLDBWNjIuNDlsMjkuNzYsMjkuNzVhNiw2LDAsMCwwLDguNDgtOC40OFoiLz48L3N2Zz4=');}.icon-sort-ascending{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjYsMTI4YTYsNiwwLDAsMS02LDZINDhhNiw2LDAsMCwxLDAtMTJoNzJBNiw2LDAsMCwxLDEyNiwxMjhaTTQ4LDcwSDE4NGE2LDYsMCwwLDAsMC0xMkg0OGE2LDYsMCwwLDAsMCwxMlptNTYsMTE2SDQ4YTYsNiwwLDAsMCwwLDEyaDU2YTYsNiwwLDAsMCwwLTEyWm0xMjQuMjQtMjIuMjRhNiw2LDAsMCwwLTguNDgsMEwxOTAsMTkzLjUxVjExMmE2LDYsMCwwLDAtMTIsMHY4MS41MWwtMjkuNzYtMjkuNzVhNiw2LDAsMCwwLTguNDgsOC40OGw0MCw0MGE2LDYsMCwwLDAsOC40OCwwbDQwLTQwQTYsNiwwLDAsMCwyMjguMjQsMTYzLjc2WiIvPjwvc3ZnPg==');}.icon-arrow-elbow-left-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzgsNzJhNiw2LDAsMCwxLTYsNkg5NFYyMDEuNTFsMzcuNzYtMzcuNzVhNiw2LDAsMCwxLDguNDgsOC40OGwtNDgsNDhhNiw2LDAsMCwxLTguNDgsMGwtNDgtNDhhNiw2LDAsMCwxLDguNDgtOC40OEw4MiwyMDEuNTFWNzJhNiw2LDAsMCwxLDYtNkgyMzJBNiw2LDAsMCwxLDIzOCw3MloiLz48L3N2Zz4=');}.icon-arrow-elbow-right-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjguMjQsMTY0LjI0bC00OCw0OGE2LDYsMCwwLDEtOC40OCwwbC00OC00OGE2LDYsMCwxLDEsOC40OC04LjQ4TDE3MCwxOTMuNTFWNzBIMzJhNiw2LDAsMCwxLDAtMTJIMTc2YTYsNiwwLDAsMSw2LDZWMTkzLjUxbDM3Ljc2LTM3Ljc1YTYsNiwwLDAsMSw4LjQ4LDguNDhaIi8+PC9zdmc+');}.icon-heart{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNzgsNDJjLTIxLDAtMzkuMjYsOS40Ny01MCwyNS4zNEMxMTcuMjYsNTEuNDcsOTksNDIsNzgsNDJhNjAuMDcsNjAuMDcsMCwwLDAtNjAsNjBjMCwyOS4yLDE4LjIsNTkuNTksNTQuMSw5MC4zMWEzMzQuNjgsMzM0LjY4LDAsMCwwLDUzLjA2LDM3LDYsNiwwLDAsMCw1LjY4LDAsMzM0LjY4LDMzNC42OCwwLDAsMCw1My4wNi0zN0MyMTkuOCwxNjEuNTksMjM4LDEzMS4yLDIzOCwxMDJBNjAuMDcsNjAuMDcsMCwwLDAsMTc4LDQyWk0xMjgsMjE3LjExQzExMS41OSwyMDcuNjQsMzAsMTU3LjcyLDMwLDEwMkE0OC4wNSw0OC4wNSwwLDAsMSw3OCw1NGMyMC4yOCwwLDM3LjMxLDEwLjgzLDQ0LjQ1LDI4LjI3YTYsNiwwLDAsMCwxMS4xLDBDMTQwLjY5LDY0LjgzLDE1Ny43Miw1NCwxNzgsNTRhNDguMDUsNDguMDUsMCwwLDEsNDgsNDhDMjI2LDE1Ny43MiwxNDQuNDEsMjA3LjY0LDEyOCwyMTcuMTFaIi8+PC9zdmc+');}.icon-dots-three{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMzgsMTI4YTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDEzOCwxMjhaTTYwLDExOGExMCwxMCwwLDEsMCwxMCwxMEExMCwxMCwwLDAsMCw2MCwxMThabTEzNiwwYTEwLDEwLDAsMSwwLDEwLDEwQTEwLDEwLDAsMCwwLDE5NiwxMThaIi8+PC9zdmc+');}.icon-logo-jakevan{--icon:url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyAgICB3aWR0aD0iMzIiICAgIGhlaWdodD0iMzIiICAgIHZpZXdCb3g9IjAgMCAzMiAzMiIgICAgdmVyc2lvbj0iMS4xIiAgICB4bWw6c3BhY2U9InByZXNlcnZlIiAgICBzdHlsZT0iY2xpcC1ydWxlOmV2ZW5vZGQ7ZmlsbC1ydWxlOmV2ZW5vZGQ7c3Ryb2tlLWxpbmVjYXA6c3F1YXJlO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDoxLjUiICAgIGlkPSJzdmcxNCIgICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiAgICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcyAgICBpZD0iZGVmczE0IiAvPjxwYXRoICAgIGQ9Ik0gMTYuNTgwMDc4LDIuMTMyODEyNSBDIDguODY0ODQ1LDIuMTMyODEyNSAxLjQ0MDYwMjEsOC4xNDY2MjE2IDIuMDMzMjAzMSwxNS44Mzk4NDQgMi41OTQ0NTg4LDIzLjEyNjY2NiA4Ljk3MTIwMTMsMjkuMDI1MTU1IDE1Ljk3MDcwMywyOS43ODcxMDkgMjQuMjIxMjI0LDMwLjY4NTI0IDMwLjQ0MDkxMSwyMy44MzQyNzcgMjkuOTc0NjA5LDE1Ljc5ODgyOCAyOS41Mjc4MTMsOC4wOTk4NjU2IDI0LjI5MTU2LDIuMTMyODEyNSAxNi41ODAwNzgsMi4xMzI4MTI1IFogbSAwLDAuNjA3NDIxOSBoIDAuMDMxMjUgViAyOS4yMzI0MjIgYyAtMC4xOTAzMjMsLTAuMDEyMTggLTAuMzgxNTIxLC0wLjAyNzgzIC0wLjU3NDIxOSwtMC4wNDg4MyBDIDkuMzEzMDQ1NywyOC40NTE2MTcgMy4xNzc4Nzc1LDIyLjc5MzA0NCAyLjYzODY3MTksMTUuNzkyOTY5IDIuMDcyNDU2LDguNDQyMDE1IDkuMjA4MTAwOSwyLjc0MDIzNDQgMTYuNTgwMDc4LDIuNzQwMjM0NCBaIG0gLTAuNDkyMTg3LDEuMTQ0NTMxMiAtMC4wOTE4LDAuMDAzOTEgQyAxMi43MjE1Niw0LjAxODM0MTMgOS41NDY0NzQzLDUuMzY0Njg3NyA3LjI1LDcuNSA0Ljk1MzUyNTcsOS42MzUzMTIzIDMuNTM2MzY0NywxMi41NjQ5OCAzLjc4OTA2MjUsMTUuODQ1NzAzIDQuMjgwODY2OCwyMi4yMzAwMzIgOS44NjA1ODc5LDI3LjM4MzYwOCAxNS45OTAyMzQsMjguMDUyNzM0IGwgMC4wODk4NCwwLjAwOTggMC4wMTk1MywtMC4xODE2NDEgLTAuMDg5ODQsLTAuMDA5OCBDIDkuOTY4NzM4OSwyNy4yMTE2NDMgNC40NTQ5NjM4LDIyLjExNjQ4MSAzLjk3MDcwMzEsMTUuODMwMDc4IDMuNzIyNjQ5MSwxMi42MDk2NDQgNS4xMTE4OTc5LDkuNzM5MDQ3NyA3LjM3NSw3LjYzNDc2NTYgOS42MzgxMDIxLDUuNTMwNDgzNiAxMi43NzQ2MjUsNC4xOTgxOTcgMTYuMDAzOTA2LDQuMDcwMzEyNSBsIDAuMDg5ODQsLTAuMDAzOTEgeiBtIDAsMS41MDE5NTMxIC0wLjA5MTgsMC4wMDM5MSBDIDEzLjEyODA1NCw1LjUwNDE5NzEgMTAuMzQ3NDM0LDYuNjg0MzQ1NCA4LjMzNTkzNzUsOC41NTQ2ODc1IDYuMzI0NDQxMSwxMC40MjUwMyA1LjA4MTMzNDEsMTIuOTkwODI3IDUuMzAyNzM0NCwxNS44NjUyMzQgNS43MzM1MzM2LDIxLjQ1NzY1NiAxMC42MjEyNjUsMjUuOTcwNTQ4IDE1Ljk5MDIzNCwyNi41NTY2NDEgbCAwLjA4OTg0LDAuMDA5OCAwLjAxOTUzLC0wLjE3OTY4NyAtMC4wODk4NCwtMC4wMDk4IEMgMTAuNzI5NDE5LDI1LjgwMDUzMiA1LjkwNzYzMDcsMjEuMzQ2MDU2IDUuNDg0Mzc1LDE1Ljg1MTU2MyA1LjI2NzYxODQsMTMuMDM3NDQyIDYuNDgyODEzMywxMC41MjY4MTIgOC40NjA5Mzc1LDguNjg3NSAxMC40MzkwNjIsNi44NDgxODgzIDEzLjE4MTEyLDUuNjg0MDUyOCAxNi4wMDM5MDYsNS41NzIyNjU2IGwgMC4wODk4NCwtMC4wMDM5MSB6IG0gMCwxLjUwMzkwNjMgLTAuMDkxOCwwLjAwMTk1IEMgMTMuNTM0NTQsNi45OTAwNjYzIDExLjE0ODQsOC4wMDQwMDcyIDkuNDIxODc1LDkuNjA5Mzc1IDcuNjk1MzQ5NywxMS4yMTQ3NDMgNi42MjgyNTU4LDEzLjQxNjY4IDYuODE4MzU5NCwxNS44ODQ3NjYgNy4xODgxNTUxLDIwLjY4NTI2OSAxMS4zODE5NiwyNC41NTk0NDQgMTUuOTkwMjM0LDI1LjA2MjUgbCAwLjA4OTg0LDAuMDA5OCAwLjAxOTUzLC0wLjE4MTY0MSAtMC4wODk4NCwtMC4wMDk4IEMgMTEuNDkwMTE0LDI0LjM4NzQ3NyA3LjM2MjI1MjIsMjAuNTczNjcxIDcsMTUuODcxMDk0IDYuODE0NTQwMSwxMy40NjMyOTcgNy44NTM3MjE5LDExLjMxNjUyNSA5LjU0Njg3NSw5Ljc0MjE4NzUgYyAxLjY5MzE1MywtMS41NzQzMzc0IDQuMDQwNzMsLTIuNTcwMzI2IDYuNDU3MDMxLC0yLjY2NjAxNTYgbCAwLjA4OTg0LC0wLjAwMzkxIHogbSAwLDEuNTAxOTUzMSAtMC4wOTE4LDAuMDAzOTEgYyAtMi4wNTUwNzMsMC4wODEzODQgLTQuMDQ2NzI4LDAuOTI1MjMzMSAtNS40ODgyODEsMi4yNjU2MjQ5IC0xLjQ0MTU1NDcsMS4zNDAzOTMgLTIuMzMyNjM1NCwzLjE4MjM3OSAtMi4xNzM4Mjg2LDUuMjQ0MTQxIDAuMzA2Mzk5NCwzLjk3NzUyIDMuNzU3OTY0Niw3LjE3NDg3NyA3LjU3MDMxMjYsNy42MzQ3NjYgbCAtMC4wMDIsMC4wMTc1OCAwLjA4Nzg5LDAuMDA3OCBoIDAuMDAyIGwgMC4wMDk4LDAuMDAyIDAuMDgwMDgsMC4wMDk4IDAuMDIxNDgsLTAuMTgxNjQxIGggLTAuMDAzOSB2IC0wLjAwMiBsIC0wLjA4OTg0LC0wLjAwOTggaCAtMC4wMDIgQyAxMi4yNDk0NzYsMjIuOTczNTY1IDguODE2ODE1NSwxOS44MDI1NzYgOC41MTU2MTcsMTUuODkyNTcxIDguMzYxNDU0LDEzLjg5MTA5OCA5LjIyNDYyMjUsMTIuMTA2MjMgMTAuNjMyODA1LDEwLjc5Njg2OCAxMi4wNDA5ODUsOS40ODc1MDU0IDEzLjk5NDA4NCw4LjY1NzcwOTcgMTYuMDAzODk4LDguNTc4MTE3NyBsIDAuMDg5ODQsLTAuMDAzOTEgeiBtIDAsMS41MDE5NTMyIC0wLjA5MTgsMC4wMDM5MSBjIC0xLjY0ODU4MiwwLjA2NTI4NyAtMy4yNDU3NjYsMC43NDI5NDI3IC00LjQwMjM0NCwxLjgxODM1OTcgLTEuMTU2NTc4LDEuMDc1NDE3IC0xLjg3MTY1MDMsMi41NTM1NDUgLTEuNzQ0MTQwNiw0LjIwODk4NCAwLjI0NTM5NDYsMy4xODU1OSAzLjAwMzEwMzYsNS43NDQzODEgNi4wNTQ2ODc2LDYuMTIxMDk0IGwgLTAuMDAyLDAuMDE3NTggMC4wODc4OSwwLjAwNzggaCAwLjAwMiBsIDAuMDg3ODksMC4wMDk4IGggMC4wMDIgbCAwLjAyMTQ4LC0wLjE4MTY0IGggLTAuMDAzOSBsIC0wLjA4Nzg5LC0wLjAwOTggaCAtMC4wMDIgLTAuMDAyIGMgLTIuOTk3NjIzLC0wLjMyODE1NiAtNS43MzYzNjgsLTIuODYwNDMzIC01Ljk3NjU2MiwtNS45Nzg1MTYgLTAuMTIyODY1OSwtMS41OTUxNSAwLjU2NDI5NCwtMy4wMTgxMTMgMS42ODc1LC00LjA2MjUgMS4xMjMyMDYsLTEuMDQ0Mzg2IDIuNjgxODMzLC0xLjcwNjAzNyA0LjI4NTE1NiwtMS43Njk1MzEgbCAwLjA4OTg0LC0wLjAwMzkgeiBtIDQuMTIxMDkzLDEuMzIwMzEyNyBoIDEuNDY2Nzk3IGwgMS4zMjgxMjUsMy45NjI4OSB2IC0zLjk0NzI2NSBoIDEuMTAzNTE2IHYgMS43MzYzMjggYyAwLjA0NjM5LC0wLjA4NzQyIDAuMDk5ODksLTAuMTc2MjEyIDAuMTYwMTU2LC0wLjI2MzY3MiAwLjA2MDMxLC0wLjA4NzQ2IDAuMTIwMjk0LC0wLjE2OTY4NyAwLjE3NzczNCwtMC4yNDgwNDcgbCAwLjg1MzUxNiwtMS4yMjQ2MDkgaCAzLjU3ODEyNSB2IDAuODY3MTg3IGggLTEuMzE2NDA2IHYgMC42MjY5NTMgaCAxLjIxODc1IHYgMC44NjcxODggaCAtMS4yMTg3NSB2IDAuNzUzOTA2IGggMS4zMTY0MDYgdiAwLjg4MDg2IGggLTMuNjM4NjcyIGwgLTAuODA0Njg3LC0xLjUyNzM0NCAtMC4zMjYxNzIsMC4xOTE0MDYgdiAxLjMzNTkzOCBoIC0yLjI4OTA2MyBsIC0wLjIwMTE3MiwtMC43NDgwNDcgaCAtMS4zMjAzMTIgbCAtMC4yMDcwMzEsMC43NDgwNDcgaCAtMS4yMDcwMzIgeiBtIC0yLjQxNDA2MiwwLjAxNTYzIGggMS4xMDM1MTUgdiAzLjYwNTQ2OSBjIDEwZS03LDAuNDAwOTA1IC0wLjA2MTE3LDAuNzIyMzU1IC0wLjE4MzU5MywwLjk2Mjg5IC0wLjEyMjQyMiwwLjI0MDUzNSAtMC4yOTA4MTMsMC40MTQwMTEgLTAuNTA3ODEzLDAuNTIxNDg0IC0wLjIxNjk5OSwwLjEwNzUxMyAtMC40NjgyNTUsMC4xNjAxNTcgLTAuNzUzOTA2LDAuMTYwMTU3IC0wLjEyNDI1NiwwIC0wLjIzNDQ2NSwtMC4wMDU3IC0wLjMyODEyNSwtMC4wMTc1OCAtMC4wOTM2NiwtMC4wMTE3NyAtMC4xNzM1NzgsLTAuMDI0NDYgLTAuMjQyMTg4LC0wLjAzOTA2IFYgMTUuNTgzOTkgYyAwLjA1OTM2LDAuMDEwODYgMC4xMjI2NzQsMC4wMjM0MSAwLjE4OTQ1NCwwLjAzNzExIDAuMDY2NzcsMC4wMTM3IDAuMTM4OTM2LDAuMDIxNDggMC4yMTY3OTYsMC4wMjE0OCAwLjEzMTY3NiwwIDAuMjMzMzYxLC0wLjAyNzIzIDAuMzA2NjQxLC0wLjA4MDA4IDAuMDczMjMsLTAuMDUyODYgMC4xMjQ2MTcsLTAuMTMyNjExIDAuMTU0Mjk3LC0wLjIzODI4MSAwLjAyOTY4LC0wLjEwNTY3IDAuMDQ0OTIsLTAuMjM3OTE5IDAuMDQ0OTIsLTAuMzk2NDg1IHogbSA4LjY2Nzk2OSwwLjA1NDY5IC0xLjI0NDE0MSwxLjczNjMyOCAxLjI0NDE0MSwyLjE3NTc4MiB6IG0gLTEwLjM3NSwwLjExMzI4MiAtMC4wOTE4LDAuMDAyIGMgLTEuMjQyMDk1LDAuMDQ5MTkgLTIuNDQ0OCwwLjU2MDY1MiAtMy4zMTY0MDYsMS4zNzEwOTMgLTAuODcxNjA4LDAuODEwNDQyIC0xLjQxMjYyLDEuOTI0NzEyIC0xLjMxNjQwNywzLjE3MzgyOSAwLjE4NDM5LDIuMzkzNjU0IDIuMjUwMTk1LDQuMzEyMDIyIDQuNTQxMDE2LDQuNjA1NDY4IGwgLTAuMDAyLDAuMDE3NTggMC4wODc4OSwwLjAwNzggaCAwLjAwMiBsIDAuMDg3ODksMC4wMDk4IGggMC4wMDIgbCAwLjAxOTUzLC0wLjE3OTY4NyBoIC0wLjAwMiB2IC0wLjAwMiBsIC0wLjA3ODEzLC0wLjAwNzggLTAuMDA5OCwtMC4wMDIgaCAtMC4wMDIgLTAuMDAyIGMgLTIuMjM2OTYxLC0wLjI0NTEyMSAtNC4yODM3LC0yLjEzNjczMSAtNC40NjI4OSwtNC40NjI4OSAtMC4wOTE1NywtMS4xODg4MjYgMC40MjE1MzEsLTIuMjQ3OTMzIDEuMjU5NzY2LC0zLjAyNzM0NCAwLjgzODIzNCwtMC43Nzk0MTEgMi4wMDIzODIsLTEuMjcyOTE2IDMuMTk5MjE4LC0xLjMyMDMxMyBsIDAuMDg5ODQsLTAuMDAzOSB6IG0gNC44NjEzMjgsMC40NzQ2MDkgYyAtMC4wMTY3MSwwLjA5MTExIC0wLjAzOTcyLDAuMjAzOTI4IC0wLjA3MDMxLDAuMzM3ODkxIC0wLjAzMDYsMC4xMzM5MjIgLTAuMDYxMjgsMC4yNjUyNjkgLTAuMDkzNzUsMC4zOTY0ODQgLTAuMDMyNDIsMC4xMzExNzUgLTAuMDYxODUsMC4yNDA2NjUgLTAuMDg1OTQsMC4zMjgxMjUgbCAtMC4xNzU3ODIsMC42NTYyNSBoIDAuODY1MjM1IGwgLTAuMTczODI4LC0wLjY1NjI1IGMgLTAuMDE4NTEsLTAuMDcxMDYgLTAuMDQ2ODEsLTAuMTcyNTI5IC0wLjA4MjAzLC0wLjMwNDY4OCAtMC4wMzUyNiwtMC4xMzIxMTggLTAuMDY5MjEsLTAuMjY4OTM1IC0wLjEwMzUxNSwtMC40MTAxNTYgLTAuMDM0MywtMC4xNDEyMjEgLTAuMDYxNTMsLTAuMjU2NTQ2IC0wLjA4MDA4LC0wLjM0NzY1NiB6IG0gLTQuODYxMzI4LDEuMDI3MzQ0IC0wLjA5MTgsMC4wMDM5IGMgLTAuODM1NjA4LDAuMDMzMDkgLTEuNjQzODM2LDAuMzc0NDU2IC0yLjIzMDQ2OSwwLjkxOTkyMiAtMC41ODY2MzMsMC41NDU0NjYgLTAuOTUxNjM1LDEuMjk5Nzg2IC0wLjg4NjcxOSwyLjE0MjU3OCAwLjEyMzIwNSwxLjU5OTM3OCAxLjQ5ODEyOCwyLjg1OTQ2NyAzLjAyNTM5MSwzLjA3MjI2NSBsIC0wLjAwMzksMC4wMzMyIDAuMDg5ODQsMC4wMDk4IDAuMDgyMDMsMC4wMDc4IDAuMDA3OCwwLjAwMiBoIDAuMDAyIGwgMC4wMTk1MywtMC4xODE2NDEgaCAtMC4wMDIgbCAtMC4wODc4OSwtMC4wMDk4IGggLTAuMDAyIC0wLjAwMiBjIC0xLjQ3NjMzNywtMC4xNjIwNDEgLTIuODI5MDc2LC0xLjQxMjk5NiAtMi45NDcyNjUsLTIuOTQ3MjY2IC0wLjA2MDI3LC0wLjc4MjUwMyAwLjI3NjgxNywtMS40Nzk3MDUgMC44MzAwNzgsLTEuOTk0MTQxIDAuNTUzMjYxLC0wLjUxNDQzNSAxLjMyMjkzMywtMC44NDE3NDggMi4xMTMyODEsLTAuODczMDQ2IGwgMC4wODk4NCwtMC4wMDM5IHogbSAwLDEuNTAxOTUzIC0wLjA5MTgsMC4wMDM5IGMgLTAuNDI5MTE4LDAuMDE2OTkgLTAuODQyODczLDAuMTkyMTY2IC0xLjE0NDUzMSwwLjQ3MjY1NiAtMC4zMDE2NiwwLjI4MDQ5MSAtMC40OTA2NDgsMC42NzA5NTUgLTAuNDU3MDMyLDEuMTA3NDIyIDAuMDYyMTksMC44MDczNiAwLjc0MzgwMSwxLjQzMDI1NyAxLjUwOTc2NiwxLjU1ODU5NCBsIC0wLjAwMzksMC4wMzMyIDAuMDg5ODQsMC4wMDc4IGggMC4wMDIgbCAwLjA4Nzg5LDAuMDA5OCAwLjAyMTQ4LC0wLjE3OTY4NyBoIC0wLjAwMiB2IC0wLjAwMiBsIC0wLjA3ODEzLC0wLjAwNzggLTAuMDA5OCwtMC4wMDIgaCAtMC4wMDIgLTAuMDAyIGMgLTAuNzE1Njc1LC0wLjA3OTAxIC0xLjM3NDQ1NSwtMC42ODkyOSAtMS40MzE2NCwtMS40MzE2NDEgLTAuMDI4OTcsLTAuMzc2MTc5IDAuMTMyMTA0LC0wLjcxMTQ3NyAwLjQwMDM5MSwtMC45NjA5MzcgMC4yNjgyODYsLTAuMjQ5NDYxIDAuNjQzNDg1LC0wLjQwODYyNyAxLjAyNzM0MywtMC40MjM4MjggbCAwLjA4OTg0LC0wLjAwMzkgeiBtIDcuMDQxMDE1LDAuODQ5NjA5IGggMS4yNjE3MTkgbCAxLjE0MjU3OCwzLjQxNDA2MyB2IC0zLjQwMDM5MSBoIDEuMjM2MzI4IGwgMS4yNzUzOTEsMi4zOTI1NzggaCAwLjAxMzY3IGMgLTAuMDA0NywtMC4wNzUzNyAtMC4wMDg5LC0wLjE2NDAzMiAtMC4wMTM2NywtMC4yNjM2NzIgLTAuMDA0OCwtMC4wOTk2NCAtMC4wMDk3LC0wLjIwMDcxMyAtMC4wMTM2NywtMC4zMDI3MzQgLTAuMDA0LC0wLjEwMjAyIC0wLjAwNTksLTAuMTkxMDUxIC0wLjAwNTksLTAuMjY5NTMxIHYgLTEuNTU2NjQxIGggMC44NDM3NSB2IDMuNDQxNDA2IGggLTEuMjQyMTg4IGwgLTEuMjc5Mjk3LC0yLjQyMzgyOCBoIC0wLjAyMTQ4IGMgMC4wMDgsMC4wNzM3NyAwLjAxNTA4LDAuMTYyMDg4IDAuMDIxNDgsMC4yNjU2MjUgMC4wMDY0LDAuMTAzNTc5IDAuMDEyODgsMC4yMDg4OTEgMC4wMTc1OCwwLjMxNjQwNiAwLjAwNDgsMC4xMDc0NzQgMC4wMDc4LDAuMjA0NzA2IDAuMDA3OCwwLjI5MTAxNiB2IDEuNTUwNzgxIEggMjQuNTEzNjcyIEwgMjQuMzM5ODQ0LDE4LjA2MjUgaCAtMS4xMzY3MTkgbCAtMC4xNzc3MzQsMC42NDQ1MzEgaCAtMS4wMzkwNjMgeiBtIC00LjE1NDI5NywwLjAxMzY3IGggMS4wNjI1IGwgMC40NzY1NjMsMS43NDQxNDEgYyAwLjAxNzU0LDAuMDY1ODkgMC4wMzkzNywwLjE1MTEwNyAwLjA2MjUsMC4yNTM5MDYgMC4wMjMxNywwLjEwMjc1OCAwLjA0NDQ4LDAuMjA0NjYxIDAuMDY0NDUsMC4zMDY2NCAwLjAxOTk3LDAuMTAyMDIgMC4wMzIzMSwwLjE4NTYyIDAuMDM3MTEsMC4yNSAwLjAwNjQsLTAuMDY0MzggMC4wMTc2MSwtMC4xNDc2MjUgMC4wMzUxNiwtMC4yNDgwNDYgMC4wMTc1OSwtMC4xMDA0MjEgMC4wMzcwNCwtMC4yMDE1MzUgMC4wNTg1OSwtMC4zMDI3MzUgMC4wMjE2LC0wLjEwMTI0MSAwLjA0MTM4LC0wLjE4NDA2IDAuMDYwNTUsLTAuMjUgbCAwLjQ4NjMyOCwtMS43NTM5MDYgaCAxLjA2MDU0NyBsIC0xLjE0ODQzNywzLjQ0MTQwNiBoIC0xLjExMzI4MiB6IG0gNC43OTEwMTYsMC41NTI3MzQgYyAtMC4wMTQyOSwwLjA3ODQ0IC0wLjAzNDIxLDAuMTc1NjY5IC0wLjA2MDU1LDAuMjkxMDE2IC0wLjAyNjM0LDAuMTE1MzQ3IC0wLjA1NDA2LDAuMjMwNzgyIC0wLjA4MjAzLDAuMzQzNzUgLTAuMDI3OTMsMC4xMTI5NjkgLTAuMDUxNDcsMC4yMDU5MiAtMC4wNzIyNywwLjI4MTI1IGwgLTAuMTUyMzQ0LDAuNTY0NDUzIGggMC43NDYwOTQgbCAtMC4xNTAzOSwtMC41NjQ0NTMgYyAtMC4wMTU5MiwtMC4wNjEyMiAtMC4wMzk4OCwtMC4xNDc5NzEgLTAuMDcwMzEsLTAuMjYxNzE5IC0wLjAzMDI3LC0wLjExMzc4OSAtMC4wNjAyOSwtMC4yMzE5MzUgLTAuMDg5ODQsLTAuMzUzNTE1IC0wLjAyOTU2LC0wLjEyMTYyIC0wLjA1MjM1LC0wLjIyMjM0MiAtMC4wNjgzNiwtMC4zMDA3ODIgeiBtIC0zLjY0NjQ4NCwyLjk5MjE4OCBIIDIwLjU2MjUgYyAwLjE3NDc3NSwwIDAuMzIwNjU4LDAuMDI5NjQgMC40Mzk0NTMsMC4wODk4NCAwLjExODc1NCwwLjA2MDIgMC4yMTAyNTUsMC4xNDg1NTYgMC4yNzE0ODQsMC4yNjU2MjUgMC4wNjEyNywwLjExNzA2OSAwLjA5MTgsMC4yNjE4NjQgMC4wOTE4LDAuNDMzNTkzIDAsMC4xNzk4MDcgLTAuMDMzMDEsMC4zMzEzIC0wLjA5NzY2LDAuNDUzMTI1IC0wLjA2NDYxLDAuMTIxODI2IC0wLjE2MDQ3NywwLjIxMzc2MSAtMC4yODcxMDksMC4yNzUzOTEgLTAuMTI2NTksMC4wNjE2NyAtMC4yODM5NjUsMC4wOTE4IC0wLjQ3MDcwMywwLjA5MTggSCAyMC4xMTkxNCBaIG0gMS40MzU1NDYsMCBoIDAuODk2NDg1IHYgMC4xMDE1NjIgaCAtMC43ODEyNSB2IDAuNjExMzI4IEggMjIuNDA2MjUgViAxOS42MjUgaCAtMC43MzYzMjggdiAwLjY5MzM1OSBoIDAuNzgxMjUgdiAwLjEwMTU2MyBoIC0wLjg5NjQ4NSB6IG0gMS4wODM5ODUsMCBoIDAuMzk4NDM3IGMgMC4xMzAwMDgsMCAwLjIzNzIyOSwwLjAxMzA5IDAuMzI0MjE5LDAuMDQxMDIgMC4wODcwMywwLjAyNzg4IDAuMTUzMTY2LDAuMDc0NzggMC4xOTcyNjYsMC4xMzg2NzIgMC4wNDQwMiwwLjA2Mzg0IDAuMDY2NDEsMC4xNDkxOTEgMC4wNjY0MSwwLjI1NzgxMyAwLDAuMDgxNDggLTAuMDE0NjIsMC4xNTI1ODcgLTAuMDQ0OTIsMC4yMTA5MzcgLTAuMDMwMywwLjA1ODM1IC0wLjA3MjAyLDAuMTA1MTc4IC0wLjEyNSwwLjE0MjU3OCAtMC4wNTMxLDAuMDM3NDggLTAuMTE0MTU0LDAuMDY2MTMgLTAuMTgzNTk0LDAuMDg1OTQgbCAwLjQ0OTIxOSwwLjczMjQyMiBIIDIzLjU4Mzk5IGwgLTAuNDIzODI4LC0wLjY5OTIxOSBoIC0wLjQwNjI1IHYgMC42OTkyMTkgaCAtMC4xMTUyMzQgeiBtIDEuMDA3ODEyLDAgaCAwLjExOTE0MSBsIDAuMzA4NTk0LDEuMTM0NzY1IGMgMC4wMDgxLDAuMDMwMDkgMC4wMTQzOCwwLjA1ODc5IDAuMDIxNDgsMC4wODU5NCAwLjAwNywwLjAyNzE1IDAuMDEzNTMsMC4wNTI4MSAwLjAxOTUzLDAuMDc4MTMgMC4wMDYsMC4wMjUzNCAwLjAxMTk4LDAuMDUwMzUgMC4wMTc1OCwwLjA3NDIyIDAuMDA1NiwwLjAyMzgzIDAuMDExMTIsMC4wNDgwNCAwLjAxNTYzLDAuMDcyMjcgMC4wMDUyLC0wLjAyNDI0IDAuMDEwNDMsLTAuMDQ5MjUgMC4wMTU2MywtMC4wNzQyMiAwLjAwNTIsLTAuMDI0OTMgMC4wMTEyOCwtMC4wNDg1NSAwLjAxNzU4LC0wLjA3NDIyIDAuMDA2NCwtMC4wMjU3MSAwLjAxMjMzLC0wLjA1MjU2IDAuMDE5NTMsLTAuMDgwMDggMC4wMDcsLTAuMDI3NTIgMC4wMTYzOSwtMC4wNTc3OSAwLjAyNTM5LC0wLjA4Nzg5IGwgMC4zMjAzMTMsLTEuMTI4OTA2IGggMC4xMTUyMzQgbCAwLjMzMzk4NSwxLjEzNjcxOSBjIDAuMDA5LDAuMDMxNTMgMC4wMTc5OSwwLjA1OTk3IDAuMDI1MzksMC4wODc4OSAwLjAwNzUsMC4wMjc4OSAwLjAxMzUzLDAuMDU0NzQgMC4wMTk1MywwLjA4MDA4IDAuMDA1OSwwLjAyNTMgMC4wMTIyOCwwLjA1MDM1IDAuMDE3NTgsMC4wNzQyMiAwLjAwNTIsMC4wMjM4MyAwLjAxMDQzLDAuMDQ4MDggMC4wMTU2MywwLjA3MjI3IDAuMDA2LC0wLjAzMzAxIDAuMDExMTgsLTAuMDY1NzIgMC4wMTc1OCwtMC4wOTc2NiAwLjAwNjMsLTAuMDMxOSAwLjAxNDQ0LC0wLjA2NDM5IDAuMDIzNDQsLTAuMDk5NjEgMC4wMDksLTAuMDM1MjYgMC4wMiwtMC4wNzUzNyAwLjAzMTI1LC0wLjExNzE4NyBsIDAuMzE0NDUzLC0xLjEzNjcxOSBoIDAuMTE5MTQxIGwgLTAuNDQ3MjY2LDEuNjA5Mzc1IGggLTAuMTExMzI4IGwgLTAuMzUxNTYyLC0xLjIxNDg0NCBjIC0wLjAwODMsLTAuMDI1NjcgLTAuMDE1MDgsLTAuMDUxMDkgLTAuMDIxNDgsLTAuMDc0MjIgLTAuMDA2MywtMC4wMjMxMyAtMC4wMTE4OCwtMC4wNDM1NCAtMC4wMTc1OCwtMC4wNjQ0NSAtMC4wMDU2LC0wLjAyMDkyIC0wLjAxMDczLC0wLjA0MTg1IC0wLjAxNTYzLC0wLjA2MDU1IC0wLjAwNDgsLTAuMDE4NjkgLTAuMDA4NywtMC4wMzU0IC0wLjAxMTcyLC0wLjA1MDc4IC0wLjAwMjksMC4wMTUzOCAtMC4wMDY0LDAuMDMxNjEgLTAuMDA5OCwwLjA0ODgzIC0wLjAwMzQsMC4wMTcyNiAtMC4wMDcyLDAuMDM0MzggLTAuMDExNzIsMC4wNTI3MyAtMC4wMDQ1LDAuMDE4MzMgLTAuMDA4NCwwLjAzODc0IC0wLjAxMzY3LDAuMDU4NTkgLTAuMDA1MiwwLjAxOTgxIC0wLjAxMTU4LDAuMDM5MjcgLTAuMDE3NTgsMC4wNjA1NSBsIC0wLjM0OTYwOSwxLjI0NDE0MSBoIC0wLjExMzI4MSB6IG0gMi4wMzUxNTcsMCBoIDAuODk2NDg0IHYgMC4xMDE1NjIgaCAtMC43NzkyOTcgdiAwLjYxMTMyOCBoIDAuNzM2MzI4IFYgMTkuNjI1IGggLTAuNzM2MzI4IHYgMC42OTMzNTkgaCAwLjc3OTI5NyB2IDAuMTAxNTYzIGggLTAuODk2NDg0IHogbSAxLjA4Mzk4NCwwIGggMC4zOTg0MzcgYyAwLjEyOTkyNiwwIDAuMjM5MTQyLDAuMDEzMDkgMC4zMjYxNzIsMC4wNDEwMiAwLjA4Njk5LDAuMDI3ODggMC4xNTEyMTMsMC4wNzQ3OCAwLjE5NTMxMywwLjEzODY3MiAwLjA0NDAyLDAuMDYzODQgMC4wNjY0MSwwLjE0OTE5MSAwLjA2NjQxLDAuMjU3ODEzIDAsMC4wODE0OCAtMC4wMTQ2MiwwLjE1MjU4NyAtMC4wNDQ5MiwwLjIxMDkzNyAtMC4wMzAyNiwwLjA1ODM1IC0wLjA3MjAyLDAuMTA1MTc4IC0wLjEyNSwwLjE0MjU3OCAtMC4wNTMwNiwwLjAzNzQ4IC0wLjExNDE1MywwLjA2NjEzIC0wLjE4MzU5NCwwLjA4NTk0IGwgMC40NDkyMTksMC43MzI0MjIgaCAtMC4xMzY3MTkgbCAtMC40MjE4NzUsLTAuNjk5MjE5IGggLTAuNDA4MjAzIHYgMC42OTkyMTkgaCAtMC4xMTUyMzQgeiBtIDEuMTgxNjQxLDAgaCAwLjg5NjQ4NCB2IDAuMTAxNTYyIEggMjguMDYyNSB2IDAuNjg3NSBoIDAuNzM4MjgxIHYgMC4wOTk2MSBIIDI4LjA2MjUgdiAwLjcyMDcwMyBoIC0wLjExNTIzNCB6IG0gLTcuNzEyODkxLDAuMDk5NjEgdiAxLjQxMDE1NiBoIDAuMjcxNDg0IGMgMC4yNDcyNjEsMCAwLjQzMjE4MywtMC4wNjA0IDAuNTU0Njg4LC0wLjE3OTY4NyAwLjEyMjU0OSwtMC4xMTkyNDIgMC4xODM1OTQsLTAuMjk4NTQyIDAuMTgzNTk0LC0wLjUzNzEwOSAwLC0wLjE1MzM1OCAtMC4wMjUzNiwtMC4yODAwNTQgLTAuMDc2MTcsLTAuMzgyODEzIC0wLjA1MDc3LC0wLjEwMjc1OCAtMC4xMjk3OTMsLTAuMTgwNjcyIC0wLjIzNDM3NSwtMC4yMzI0MjIgLTAuMTA0NTgyLC0wLjA1MTcxIC0wLjIzNTg4MiwtMC4wNzgxMyAtMC4zOTY0ODUsLTAuMDc4MTMgeiBtIDIuNTE5NTMxLDAgdiAwLjcwODk4NSBoIDAuMzI2MTcyIGMgMC4xMzM3NiwwIDAuMjM3NDcsLTAuMDMwNjQgMC4zMTI1LC0wLjA5Mzc1IDAuMDc1MTYsLTAuMDYzMTUgMC4xMTMyODEsLTAuMTUzMzUgMC4xMTMyODEsLTAuMjcxNDg1IDAsLTAuMTI5OTQ0IC0wLjAzOTk4LC0wLjIyMDMyMSAtMC4xMTkxNCwtMC4yNjk1MzEgLTAuMDc5MjQsLTAuMDQ5MTcgLTAuMTk5MjI0LC0wLjA3NDIyIC0wLjM2MTMyOCwtMC4wNzQyMiB6IG0gNC4xMjY5NTMsMCB2IDAuNzA4OTg1IGggMC4zMjYxNzIgYyAwLjEzMzc1OSwwIDAuMjM5NDIzLC0wLjAzMDY0IDAuMzE0NDUzLC0wLjA5Mzc1IDAuMDc1MTIsLTAuMDYzMTUgMC4xMTEzMjgsLTAuMTUzMzUgMC4xMTEzMjgsLTAuMjcxNDg1IDAsLTAuMTI5OTQ0IC0wLjAzODA4LC0wLjIyMDMyMSAtMC4xMTcxODcsLTAuMjY5NTMxIC0wLjA3OTI0LC0wLjA0OTE3IC0wLjIwMTE3OCwtMC4wNzQyMiAtMC4zNjMyODEsLTAuMDc0MjIgeiIgICAgc3R5bGU9ImJhc2VsaW5lLXNoaWZ0OmJhc2VsaW5lO2NsaXAtcnVsZTpub256ZXJvO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7dmVjdG9yLWVmZmVjdDpub25lO2ZpbGw6IzIyMjIyMjtmaWxsLXJ1bGU6bm9uemVybztzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlO3N0b3AtY29sb3I6IzAwMDAwMDtzdG9wLW9wYWNpdHk6MSIgICAgaWQ9InBhdGgxNyIgLz48L3N2Zz4=');}.icon-sign-out{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMTgsMjE2YTYsNiwwLDAsMS02LDZINDhhNiw2LDAsMCwxLTYtNlY0MGE2LDYsMCwwLDEsNi02aDY0YTYsNiwwLDAsMSwwLDEySDU0VjIxMGg1OEE2LDYsMCwwLDEsMTE4LDIxNlptMTEwLjI0LTkyLjI0LTQwLTQwYTYsNiwwLDAsMC04LjQ4LDguNDhMMjA5LjUxLDEyMkgxMTJhNiw2LDAsMCwwLDAsMTJoOTcuNTFsLTI5Ljc1LDI5Ljc2YTYsNiwwLDEsMCw4LjQ4LDguNDhsNDAtNDBBNiw2LDAsMCwwLDIyOC4yNCwxMjMuNzZaIi8+PC9zdmc+');}.icon-arrow-clockwise{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzgsNTZ2NDhhNiw2LDAsMCwxLTYsNkgxODRhNiw2LDAsMCwxLDAtMTJoMzIuNTVsLTMwLjM4LTI3LjhjLS4wNi0uMDYtLjEyLS4xMy0uMTktLjE5YTgyLDgyLDAsMSwwLTEuNywxMTcuNjUsNiw2LDAsMCwxLDguMjQsOC43M0E5My40Niw5My40NiwwLDAsMSwxMjgsMjIyaC0xLjI4QTk0LDk0LDAsMSwxLDE5NC4zNyw2MS40TDIyNiw5MC4zNVY1NmE2LDYsMCwxLDEsMTIsMFoiLz48L3N2Zz4=');}.icon-x-square{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMzRINDhBMTQsMTQsMCwwLDAsMzQsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFY0OEExNCwxNCwwLDAsMCwyMDgsMzRabTIsMTc0YTIsMiwwLDAsMS0yLDJINDhhMiwyLDAsMCwxLTItMlY0OGEyLDIsMCwwLDEsMi0ySDIwOGEyLDIsMCwwLDEsMiwyWk0xNjQuMjQsMTAwLjI0LDEzNi40OCwxMjhsMjcuNzYsMjcuNzZhNiw2LDAsMSwxLTguNDgsOC40OEwxMjgsMTM2LjQ4bC0yNy43NiwyNy43NmE2LDYsMCwwLDEtOC40OC04LjQ4TDExOS41MiwxMjgsOTEuNzYsMTAwLjI0YTYsNiwwLDAsMSw4LjQ4LTguNDhMMTI4LDExOS41MmwyNy43Ni0yNy43NmE2LDYsMCwwLDEsOC40OCw4LjQ4WiIvPjwvc3ZnPg==');}.icon-eye-closed{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjkuMjEsMTY1YTYsNiwwLDAsMS0xMC40Miw2bC0yMC0zNS4wOGExMjIsMTIyLDAsMCwxLTM5LDE4LjA5bDYuMTcsMzdhNiw2LDAsMCwxLTQuOTMsNi45MSw2Ljg1LDYuODUsMCwwLDEtMSwuMDgsNiw2LDAsMCwxLTUuOTEtNUwxNDgsMTU2LjQ0YTEyOC44NiwxMjguODYsMCwwLDEtNDAsMEwxMDEuOTIsMTkzQTYsNiwwLDAsMSw5NiwxOThhNi44NSw2Ljg1LDAsMCwxLTEtLjA4QTYsNiwwLDAsMSw5MC4wOCwxOTFsNi4xNy0zN2ExMjIsMTIyLDAsMCwxLTM5LTE4LjA5TDM3LjIxLDE3MWE2LDYsMCwxLDEtMTAuNDItNmwyMC44NS0zNi40OGExNTIsMTUyLDAsMCwxLTIwLjMxLTIwLjc3LDYsNiwwLDAsMSw5LjM0LTcuNTRDNTMuNTQsMTIxLjExLDgzLjA3LDE0NiwxMjgsMTQ2czc0LjQ2LTI0Ljg5LDkxLjMzLTQ1Ljc3YTYsNiwwLDAsMSw5LjM0LDcuNTQsMTUyLDE1MiwwLDAsMS0yMC4zMSwyMC43N1oiLz48L3N2Zz4=');}.icon-hourglass{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xOTgsNzUuNjRWNDBhMTQsMTQsMCwwLDAtMTQtMTRINzJBMTQsMTQsMCwwLDAsNTgsNDBWNzZhMTQuMDYsMTQuMDYsMCwwLDAsNS42LDExLjJMMTE4LDEyOCw2My42LDE2OC44QTE0LjA2LDE0LjA2LDAsMCwwLDU4LDE4MHYzNmExNCwxNCwwLDAsMCwxNCwxNEgxODRhMTQsMTQsMCwwLDAsMTQtMTRWMTgwLjM2YTE0LjA4LDE0LjA4LDAsMCwwLTUuNTYtMTEuMTdMMTM4LDEyOGw1NC40OS00MS4xOUExNC4wOCwxNC4wOCwwLDAsMCwxOTgsNzUuNjRaTTE4NiwxODAuMzZWMjE2YTIsMiwwLDAsMS0yLDJINzJhMiwyLDAsMCwxLTItMlYxODBhMiwyLDAsMCwxLC44LTEuNkwxMjgsMTM1LjUxbDU3LjIyLDQzLjI1QTIsMiwwLDAsMSwxODYsMTgwLjM2Wm0wLTEwNC43MmEyLDIsMCwwLDEtLjc5LDEuNkwxMjgsMTIwLjQ5LDcwLjgsNzcuNkEyLDIsMCwwLDEsNzAsNzZWNDBhMiwyLDAsMCwxLDItMkgxODRhMiwyLDAsMCwxLDIsMloiLz48L3N2Zz4=');}.icon-star-half-fi{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzkuMTgsOTcuMjZBMTYuMzgsMTYuMzgsMCwwLDAsMjI0LjkyLDg2bC01OS00Ljc2TDE0My4xNCwyNi4xNWExNi4zNiwxNi4zNiwwLDAsMC0zMC4yNywwTDkwLjExLDgxLjIzLDMxLjA4LDg2YTE2LjQ2LDE2LjQ2LDAsMCwwLTkuMzcsMjguODZsNDUsMzguODNMNTMsMjExLjc1YTE2LjQsMTYuNCwwLDAsMCwyNC41LDE3LjgyTDEyOCwxOTguNDlsNTAuNTMsMzEuMDhBMTYuNCwxNi40LDAsMCwwLDIwMywyMTEuNzVsLTEzLjc2LTU4LjA3LDQ1LTM4LjgzQTE2LjQzLDE2LjQzLDAsMCwwLDIzOS4xOCw5Ny4yNlptLTE1LjM0LDUuNDctNDguNyw0MmE4LDgsMCwwLDAtMi41Niw3LjkxbDE0Ljg4LDYyLjhhLjM3LjM3LDAsMCwxLS4xNy40OGMtLjE4LjE0LS4yMy4xMS0uMzgsMGwtNTQuNzItMzMuNjVBOCw4LDAsMCwwLDEyOCwxODEuMVYzMmMuMjQsMCwuMjcuMDguMzUuMjZMMTUzLDkxLjg2YTgsOCwwLDAsMCw2Ljc1LDQuOTJsNjMuOTEsNS4xNmMuMTYsMCwuMjUsMCwuMzQuMjlTMjI0LDEwMi42MywyMjMuODQsMTAyLjczWiIvPjwvc3ZnPg==');}.icon-star-fi{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzQuMjksMTE0Ljg1bC00NSwzOC44M0wyMDMsMjExLjc1YTE2LjQsMTYuNCwwLDAsMS0yNC41LDE3LjgyTDEyOCwxOTguNDksNzcuNDcsMjI5LjU3QTE2LjQsMTYuNCwwLDAsMSw1MywyMTEuNzVsMTMuNzYtNTguMDctNDUtMzguODNBMTYuNDYsMTYuNDYsMCwwLDEsMzEuMDgsODZsNTktNC43NiwyMi43Ni01NS4wOGExNi4zNiwxNi4zNiwwLDAsMSwzMC4yNywwbDIyLjc1LDU1LjA4LDU5LDQuNzZhMTYuNDYsMTYuNDYsMCwwLDEsOS4zNywyOC44NloiLz48L3N2Zz4=');}.icon-heart-fi{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDAsMTAyYzAsNzAtMTAzLjc5LDEyNi42Ni0xMDguMjEsMTI5YTgsOCwwLDAsMS03LjU4LDBDMTE5Ljc5LDIyOC42NiwxNiwxNzIsMTYsMTAyQTYyLjA3LDYyLjA3LDAsMCwxLDc4LDQwYzIwLjY1LDAsMzguNzMsOC44OCw1MCwyMy44OUMxMzkuMjcsNDguODgsMTU3LjM1LDQwLDE3OCw0MEE2Mi4wNyw2Mi4wNywwLDAsMSwyNDAsMTAyWiIvPjwvc3ZnPg==');} |
| | | .icon-google-logo{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsMTI4YTk0LDk0LDAsMSwxLTIxLjQ5LTU5LjgyLDYsNiwwLDEsMS05LjI1LDcuNjRBODIsODIsMCwxLDAsMjA5Ljc4LDEzNEgxMjhhNiw2LDAsMCwxLDAtMTJoODhBNiw2LDAsMCwxLDIyMiwxMjhaIi8+PC9zdmc+');}.icon-apple-logo{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTkuNCwxNjcuODRDMjAxLjcxLDE1NS42OSwxOTgsMTM1LjEyLDE5OCwxMjBjMC0xOC40MiwxMy44Ni0zNC4yOSwyMi4xMi00Mi4xMmE2LDYsMCwwLDAsMC04LjcxQzIwOCw1Ny43LDE4Ny4wNyw1MCwxNjgsNTBhNzAuMjMsNzAuMjMsMCwwLDAtNDAsMTIuNTUsNjkuNiw2OS42LDAsMCwwLTg5LjMxLDguMDhBNzIuNjMsNzIuNjMsMCwwLDAsMTgsMTIzLjM1YTEyNS4xMSwxMjUuMTEsMCwwLDAsMzkuNTMsODguMzNBMzcuODUsMzcuODUsMCwwLDAsODMuNiwyMjJoODcuN0EzNy44MywzNy44MywwLDAsMCwxOTksMjEwLjA3YTEyMi42LDEyMi42LDAsMCwwLDE3LjU0LTI0LjJjNi41NS0xMiw1Ljc3LTEzLjc1LDUtMTUuNDhBNi4wNyw2LjA3LDAsMCwwLDIxOS40LDE2Ny44NFptLTI5LjIzLDM0QTI1LjgyLDI1LjgyLDAsMCwxLDE3MS4zLDIxMEg4My42QTI1Ljg1LDI1Ljg1LDAsMCwxLDY1Ljc4LDIwMywxMTMuMjEsMTEzLjIxLDAsMCwxLDMwLDEyM2E2MC41NSw2MC41NSwwLDAsMSwxNy4yMS00NEE1Ni44Miw1Ni44MiwwLDAsMSw4OCw2MmguODFhNTcuMzUsNTcuMzUsMCwwLDEsMzUuNDQsMTIuNzEsNiw2LDAsMCwwLDcuNSwwQTU3LjM5LDU3LjM5LDAsMCwxLDE2OCw2MmMxMy44OSwwLDI4LjgxLDQuNjgsMzkuMTEsMTItOS40NCwxMC4xNC0yMS4xLDI2LjU5LTIxLjEsNDYsMCwyMy43OCw3LjgxLDQyLjYsMjIuNjYsNTQuNzdBMTA3LjMzLDEwNy4zMywwLDAsMSwxOTAuMTcsMjAxLjg5Wm0tNjAtMTcxLjM5QTM4LDM4LDAsMCwxLDE2NywyaDFhNiw2LDAsMCwxLDAsMTJoLTFhMjYsMjYsMCwwLDAtMjUuMTgsMTkuNSw2LDYsMCwxLDEtMTEuNjItM1oiLz48L3N2Zz4=');}.icon-check-circle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNzIuMjQsOTkuNzZhNiw2LDAsMCwxLDAsOC40OGwtNTYsNTZhNiw2LDAsMCwxLTguNDgsMGwtMjQtMjRhNiw2LDAsMCwxLDguNDgtOC40OEwxMTIsMTUxLjUxbDUxLjc2LTUxLjc1QTYsNiwwLDAsMSwxNzIuMjQsOTkuNzZaTTIzMCwxMjhBMTAyLDEwMiwwLDEsMSwxMjgsMjYsMTAyLjEyLDEwMi4xMiwwLDAsMSwyMzAsMTI4Wm0tMTIsMGE5MCw5MCwwLDEsMC05MCw5MEE5MC4xLDkwLjEsMCwwLDAsMjE4LDEyOFoiLz48L3N2Zz4=');}.icon-cloud-slash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik01Mi40NCwzNkE2LDYsMCwwLDAsNDMuNTYsNDRsNDAuMTgsNDQuMmMtLjQ1Ljg3LS45LDEuNzUtMS4zMiwyLjY0QTYyLDYyLDAsMSwwLDcyLDIxNGg4OGE4NS4yMyw4NS4yMywwLDAsMCwzMi4zNS02LjNMMjAzLjU2LDIyMGE2LDYsMCwwLDAsOC44OC04LjA4Wk0xNjAsMjAySDcyYTUwLDUwLDAsMSwxLDUuOS05OS42NEE4Ni4yNSw4Ni4yNSwwLDAsMCw3NCwxMjhhNiw2LDAsMCwwLDEyLDAsNzMuOTIsNzMuOTIsMCwwLDEsNi40NC0zMC4ybDkxLjIyLDEwMC4zNEE3My42NSw3My42NSwwLDAsMSwxNjAsMjAyWm04Ni03NGE4NS44NSw4NS44NSwwLDAsMS0yMS44NSw1Ny4yNyw2LDYsMCwwLDEtNC40NywyLDYsNiwwLDAsMS00LjQ3LTEwLDc0LDc0LDAsMCwwLTk5LTEwOC45Miw2LDYsMCwxLDEtNy4xMS05LjY3QTg2LDg2LDAsMCwxLDI0NiwxMjhaIi8+PC9zdmc+');}.icon-exclamation-mark{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNDIsMjAwYTE0LDE0LDAsMSwxLTE0LTE0QTE0LDE0LDAsMCwxLDE0MiwyMDBabS0xNC00MmE2LDYsMCwwLDAsNi02VjQ4YTYsNiwwLDAsMC0xMiwwVjE1MkE2LDYsMCwwLDAsMTI4LDE1OFoiLz48L3N2Zz4=');}.icon-cloud-arrow-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsMTI4YTg1LjI3LDg1LjI3LDAsMCwxLTE3LjIsNTEuNiw2LDYsMCwxLDEtOS42LTcuMkE3NCw3NCwwLDEsMCw4NiwxMjhhNiw2LDAsMCwxLTEyLDAsODUuNTQsODUuNTQsMCwwLDEsMy45MS0yNS42NEE1MC42OCw1MC42OCwwLDAsMCw3MiwxMDJhNTAsNTAsMCwwLDAsMCwxMDBIOTZhNiw2LDAsMCwxLDAsMTJINzJBNjIsNjIsMCwxLDEsODIuNDMsOTAuODgsODYsODYsMCwwLDEsMjQ2LDEyOFptLTY2LjI0LDQzLjc2TDE1OCwxOTMuNTFWMTI4YTYsNiwwLDAsMC0xMiwwdjY1LjUxbC0yMS43Ni0yMS43NWE2LDYsMCwwLDAtOC40OCw4LjQ4bDMyLDMyYTYsNiwwLDAsMCw4LjQ4LDBsMzItMzJhNiw2LDAsMCwwLTguNDgtOC40OFoiLz48L3N2Zz4=');}.icon-caret-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTIuMjQsMTAwLjI0bC04MCw4MGE2LDYsMCwwLDEtOC40OCwwbC04MC04MGE2LDYsMCwwLDEsOC40OC04LjQ4TDEyOCwxNjcuNTFsNzUuNzYtNzUuNzVhNiw2LDAsMCwxLDguNDgsOC40OFoiLz48L3N2Zz4=');}.icon-cloud-arrow-up{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xODguMjQsMTY0LjI0YTYsNiwwLDAsMS04LjQ4LDBMMTU4LDE0Mi40OVYyMDhhNiw2LDAsMCwxLTEyLDBWMTQyLjQ5bC0yMS43NiwyMS43NWE2LDYsMCwwLDEtOC40OC04LjQ4bDMyLTMyYTYsNiwwLDAsMSw4LjQ4LDBsMzIsMzJBNiw2LDAsMCwxLDE4OC4yNCwxNjQuMjRaTTE2MCw0MkE4Ni4xLDg2LjEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDQwYTYsNiwwLDAsMCwwLTEySDcyYTUwLDUwLDAsMCwxLDAtMTAwLDUwLjY4LDUwLjY4LDAsMCwxLDUuOTEuMzZBODUuNTQsODUuNTQsMCwwLDAsNzQsMTI4YTYsNiwwLDAsMCwxMiwwLDc0LDc0LDAsMSwxLDEwMy42LDY3Ljg1LDYsNiwwLDAsMCw0LjgsMTFBODYsODYsMCwwLDAsMTYwLDQyWiIvPjwvc3ZnPg==');}.icon-cloud-check{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjAsNDJBODYuMTEsODYuMTEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDg4YTg2LDg2LDAsMCwwLDAtMTcyWm0wLDE2MEg3MmE1MCw1MCwwLDAsMSwwLTEwMCw1MC42Nyw1MC42NywwLDAsMSw1LjkxLjM1QTg1LjYxLDg1LjYxLDAsMCwwLDc0LDEyOGE2LDYsMCwwLDAsMTIsMCw3NCw3NCwwLDEsMSw3NCw3NFptMzYuMjQtOTQuMjRhNiw2LDAsMCwxLDAsOC40OGwtNDgsNDhhNiw2LDAsMCwxLTguNDgsMGwtMjQtMjRhNiw2LDAsMCwxLDguNDgtOC40OEwxNDQsMTUxLjUxbDQzLjc2LTQzLjc1QTYsNiwwLDAsMSwxOTYuMjQsMTA3Ljc2WiIvPjwvc3ZnPg==');}.icon-cloud-warning{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjAsNDJBODYuMTEsODYuMTEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDg4YTg2LDg2LDAsMCwwLDAtMTcyWm0wLDE2MEg3MmE1MCw1MCwwLDAsMSwwLTEwMCw1MC42Nyw1MC42NywwLDAsMSw1LjkxLjM1QTg1LjYxLDg1LjYxLDAsMCwwLDc0LDEyOGE2LDYsMCwwLDAsMTIsMCw3NCw3NCwwLDEsMSw3NCw3NFptLTYtNzRWODhhNiw2LDAsMCwxLDEyLDB2NDBhNiw2LDAsMCwxLTEyLDBabTE2LDM2YTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDE3MCwxNjRaIi8+PC9zdmc+');}.icon-syncing{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iY3VycmVudENvbG9yIiB2aWV3Qm94PSIwIDAgMjU2IDI1NiI+PHBhdGggaWQ9InJlZnJlc2giIGQ9Ik0xNjAuMDQ3IDEyMi44NzVhMzAuNzg0IDMwLjc4NCAwIDAgMC0yMS43NSA4Ljc5N2MtMi44NDIgMy4wMDMtLjQ2NyA0Ljk3MSAxLjMxMiAzLjE1NiAxMS4wNDMtMTAuNzg2IDI4LjcxLTEwLjY4IDM5LjYyNS4yMzRsNy4yMDMgNy4yMDRoLTEyLjg3NWMtMy4zNDcuMDA4LTMuMTY1IDMuODc1IDAgMy44NzVoMTYuMTFjMi4wNjIgMCAyLjU0LTEuNDE4IDIuNTYyLTQuOTdsLjA5NC0xNC45MjFjLjAyLTMuMjktMy40MzctMy4xNjUtMy40MzcgMHYxMi44NmwtNy4yMDMtNy4xODhhMzAuNzY4IDMwLjc2OCAwIDAgMC0yMS42NDEtOS4wNDd6bS0yOS41OTQgMzkuNzk3Yy0yLjA2MiAwLTIuNTI0IDEuNDAyLTIuNTQ3IDQuOTUzbC0uMDk0IDE0LjkyMmMtLjAyIDMuMjkgMy40MjIgMy4xNjQgMy40MjIgMHYtMTIuODZsNy4yMDMgNy4yMDRjMTEuOTU2IDExLjk1NSAzMS4zMTIgMTIuMDY0IDQzLjQwNy4yNSAyLjg0Mi0zLjAwMy40NTEtNC45ODgtMS4zMjgtMy4xNzItMTEuMDQzIDEwLjc4Ni0yOC43MSAxMC42OC0zOS42MjUtLjIzNWwtNy4xODgtNy4yMDNoMTIuODZjMy4zNDctLjAwOCAzLjE2NS0zLjg2IDAtMy44NmgtMTYuMTF6Ii8+PHBhdGggZD0iTTE2MCA0NGE4NC4xMSA4NC4xMSAwIDAgMC03Ni40MSA0OS4xMkE2MC43MSA2MC43MSAwIDAgMCA3MiA5MmE2MCA2MCAwIDAgMCAwIDEyMGg4OGE4NCA4NCAwIDAgMCAwLTE2OFptMCAxNjBINzJhNTIgNTIgMCAxIDEgOC41NS0xMDMuM0E4My42NiA4My42NiAwIDAgMCA3NiAxMjhhNCA0IDAgMCAwIDggMCA3NiA3NiAwIDEgMSA3NiA3NloiLz48L3N2Zz4=');}.icon-cloud-x{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjAsNDJBODYuMTEsODYuMTEsMCwwLDAsODIuNDMsOTAuODgsNjIsNjIsMCwxLDAsNzIsMjE0aDg4YTg2LDg2LDAsMCwwLDAtMTcyWm0wLDE2MEg3MmE1MCw1MCwwLDAsMSwwLTEwMCw1MC42Nyw1MC42NywwLDAsMSw1LjkxLjM1QTg1LjYxLDg1LjYxLDAsMCwwLDc0LDEyOGE2LDYsMCwwLDAsMTIsMCw3NCw3NCwwLDEsMSw3NCw3NFptMjguMjQtODUuNzZMMTY4LjQ4LDEzNmwxOS43NiwxOS43NmE2LDYsMCwxLDEtOC40OCw4LjQ4TDE2MCwxNDQuNDhsLTE5Ljc2LDE5Ljc2YTYsNiwwLDAsMS04LjQ4LTguNDhMMTUxLjUyLDEzNmwtMTkuNzYtMTkuNzZhNiw2LDAsMCwxLDguNDgtOC40OEwxNjAsMTI3LjUybDE5Ljc2LTE5Ljc2YTYsNiwwLDAsMSw4LjQ4LDguNDhaIi8+PC9zdmc+');}.icon-arrows-clockwise{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsNDhWOTZhNiw2LDAsMCwxLTYsNkgxNjhhNiw2LDAsMCwxLDAtMTJoMzMuNTJMMTgzLjQ3LDcyYTgxLjUxLDgxLjUxLDAsMCwwLTU3LjUzLTI0aC0uNDZBODEuNSw4MS41LDAsMCwwLDY4LjE5LDcxLjI4YTYsNiwwLDEsMS04LjM4LTguNTgsOTMuMzgsOTMuMzgsMCwwLDEsNjUuNjctMjYuNzZIMTI2YTkzLjQ1LDkzLjQ1LDAsMCwxLDY2LDI3LjUzbDE4LDE4VjQ4YTYsNiwwLDAsMSwxMiwwWk0xODcuODEsMTg0LjcyYTgxLjUsODEuNSwwLDAsMS01Ny4yOSwyMy4zNGgtLjQ2YTgxLjUxLDgxLjUxLDAsMCwxLTU3LjUzLTI0TDU0LjQ4LDE2Nkg4OGE2LDYsMCwwLDAsMC0xMkg0MGE2LDYsMCwwLDAtNiw2djQ4YTYsNiwwLDAsMCwxMiwwVjE3NC40OGwxOCwxOC4wNWE5My40NSw5My40NSwwLDAsMCw2NiwyNy41M2guNTJhOTMuMzgsOTMuMzgsMCwwLDAsNjUuNjctMjYuNzYsNiw2LDAsMSwwLTguMzgtOC41OFoiLz48L3N2Zz4=');}.icon-share-fat{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzYuMjQsMTA3Ljc2bC04MC04MEE2LDYsMCwwLDAsMTQ2LDMyVjc0LjJjLTU0LjQ4LDMuNTktMTIwLjM5LDU1LTEyNy45MywxMjAuNjZhMTAsMTAsMCwwLDAsMTcuMjMsOGgwQzQ2LjU2LDE5MC44NSw4NywxNTIuNiwxNDYsMTUwLjEzVjE5MmE2LDYsMCwwLDAsMTAuMjQsNC4yNGw4MC04MEE2LDYsMCwwLDAsMjM2LjI0LDEwNy43NlpNMTU4LDE3Ny41MlYxNDRhNiw2LDAsMCwwLTYtNmMtMjcuNzMsMC01NC43Niw3LjI1LTgwLjMyLDIxLjU1YTE5My4zOCwxOTMuMzgsMCwwLDAtNDAuODEsMzAuNjVjNC43LTI2LjU2LDIwLjE2LTUyLDQ0LTcyLjI3Qzk4LjQ3LDk3Ljk0LDEyNy4yOSw4NiwxNTIsODZhNiw2LDAsMCwwLDYtNlY0Ni40OUwyMjMuNTEsMTEyWiIvPjwvc3ZnPg==');}.icon-trash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTYsNTBIMTc0VjQwYTIyLDIyLDAsMCwwLTIyLTIySDEwNEEyMiwyMiwwLDAsMCw4Miw0MFY1MEg0MGE2LDYsMCwwLDAsMCwxMkg1MFYyMDhhMTQsMTQsMCwwLDAsMTQsMTRIMTkyYTE0LDE0LDAsMCwwLDE0LTE0VjYyaDEwYTYsNiwwLDAsMCwwLTEyWk05NCw0MGExMCwxMCwwLDAsMSwxMC0xMGg0OGExMCwxMCwwLDAsMSwxMCwxMFY1MEg5NFpNMTk0LDIwOGEyLDIsMCwwLDEtMiwySDY0YTIsMiwwLDAsMS0yLTJWNjJIMTk0Wk0xMTAsMTA0djY0YTYsNiwwLDAsMS0xMiwwVjEwNGE2LDYsMCwwLDEsMTIsMFptNDgsMHY2NGE2LDYsMCwwLDEtMTIsMFYxMDRhNiw2LDAsMCwxLDEyLDBaIi8+PC9zdmc+');}.icon-star{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzcuMjgsOTcuODdBMTQuMTgsMTQuMTgsMCwwLDAsMjI0Ljc2LDg4bC02MC4yNS00Ljg3LTIzLjIyLTU2LjJhMTQuMzcsMTQuMzcsMCwwLDAtMjYuNTgsMEw5MS40OSw4My4xMSwzMS4yNCw4OGExNC4xOCwxNC4xOCwwLDAsMC0xMi41Miw5Ljg5QTE0LjQzLDE0LjQzLDAsMCwwLDIzLDExMy4zMkw2OSwxNTIuOTNsLTE0LDU5LjI1YTE0LjQsMTQuNCwwLDAsMCw1LjU5LDE1LDE0LjEsMTQuMSwwLDAsMCwxNS45MS42TDEyOCwxOTYuMTJsNTEuNTgsMzEuNzFhMTQuMSwxNC4xLDAsMCwwLDE1LjkxLS42LDE0LjQsMTQuNCwwLDAsMCw1LjU5LTE1bC0xNC01OS4yNUwyMzMsMTEzLjMyQTE0LjQzLDE0LjQzLDAsMCwwLDIzNy4yOCw5Ny44N1ptLTEyLjE0LDYuMzctNDguNjksNDJhNiw2LDAsMCwwLTEuOTIsNS45MmwxNC44OCw2Mi43OWEyLjM1LDIuMzUsMCwwLDEtLjk1LDIuNTcsMi4yNCwyLjI0LDAsMCwxLTIuNi4xTDEzMS4xNCwxODRhNiw2LDAsMCwwLTYuMjgsMEw3MC4xNCwyMTcuNjFhMi4yNCwyLjI0LDAsMCwxLTIuNi0uMSwyLjM1LDIuMzUsMCwwLDEtMS0yLjU3bDE0Ljg4LTYyLjc5YTYsNiwwLDAsMC0xLjkyLTUuOTJsLTQ4LjY5LTQyYTIuMzcsMi4zNywwLDAsMS0uNzMtMi42NSwyLjI4LDIuMjgsMCwwLDEsMi4wNy0xLjY1bDYzLjkyLTUuMTZhNiw2LDAsMCwwLDUuMDYtMy42OWwyNC42My01OS42YTIuMzUsMi4zNSwwLDAsMSw0LjM4LDBsMjQuNjMsNTkuNmE2LDYsMCwwLDAsNS4wNiwzLjY5bDYzLjkyLDUuMTZhMi4yOCwyLjI4LDAsMCwxLDIuMDcsMS42NUEyLjM3LDIuMzcsMCwwLDEsMjI1LjE0LDEwNC4yNFoiLz48L3N2Zz4=');}.icon-alphabetical{--icon:url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIGZpbGw9ImN1cnJlbnRDb2xvciIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIDAgMTgzLjc4IDE4NC4wNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJtNTkuNTg2IDY5Ljc0MmMtMC44NTEzIDAtMS40NjEgMC4xOTY1Ni0xLjgzNjYgMC41OTcxOC0wLjM1MDU0IDAuMzc1NTgtMC41Mjk1OCAxLjAyMjktMC41Mjk1OCAxLjk0OTNzMC4xNzkwMyAxLjU5MzcgMC41Mjk1OCAxLjk5NDRjMC4zNzU1OCAwLjM3NTU4IDAuOTg1MjkgMC41NjMzOCAxLjgzNjYgMC41NjMzOGg3LjAxOTdsLTEyLjQyOCAzNC4zNjZoLTIuMTA3Yy0wLjg1MTMgMC0xLjQ2MSAwLjE5NjU2LTEuODM2NiAwLjU5NzE4LTAuMzUwNTQgMC4zNzU1OC0wLjUyOTU3IDEuMDM0MS0wLjUyOTU3IDEuOTYwNiAwIDAuOTI2NDQgMC4xNzkwMyAxLjU4MjUgMC41Mjk1NyAxLjk4MyAwLjM3NTU4IDAuMzc1NTkgMC45ODUyOSAwLjU2MzM4IDEuODM2NiAwLjU2MzM4aDEyLjU1MmMwLjg1MTMgMCAxLjQ1MjItMC4xODc3OSAxLjgwMjgtMC41NjMzOCAwLjM3NTU4LTAuNDAwNjIgMC41NjMzNy0xLjA1NjYgMC41NjMzNy0xLjk4MyAwLTAuOTI2NDUtMC4xODc3OS0xLjU4NS0wLjU2MzM3LTEuOTYwNi0wLjM1MDU0LTAuNDAwNjItMC45NTE0Ny0wLjU5NzE4LTEuODAyOC0wLjU5NzE4aC00LjU1MjFsMy4xMjExLTguOTM0OWgxOC4yMmwzLjA3NiA4LjkzNDloLTUuMDcwNGMtMC44NTEzIDAtMS40NjEgMC4xOTY1Ni0xLjgzNjYgMC41OTcxOC0wLjM1MDU0IDAuMzc1NTgtMC41Mjk1OCAxLjAzNDEtMC41Mjk1OCAxLjk2MDYgMCAwLjkyNjQ0IDAuMTc5MDMgMS41ODI1IDAuNTI5NTggMS45ODMgMC4zNzU1OCAwLjM3NTU5IDAuOTg1MjkgMC41NjMzOCAxLjgzNjYgMC41NjMzOGgxMy4yOTZjMC44NTEzIDAgMS40NTIyLTAuMTg3NzkgMS44MDI4LTAuNTYzMzggMC4zNzU1OC0wLjQwMDYyIDAuNTYzMzctMS4wNTY2IDAuNTYzMzctMS45ODMgMC0wLjkyNjQ1LTAuMTg3NzktMS41ODUtMC41NjMzNy0xLjk2MDYtMC4zNTA1NC0wLjQwMDYyLTAuOTUxNDctMC41OTcxOC0xLjgwMjgtMC41OTcxOGgtMi4yODczbC0xMy4yNjItMzcuMDM2Yy0wLjMwMDQ3LTAuODUxMy0wLjc1OTk0LTEuNDYxLTEuMzg1OS0xLjgzNjYtMC42MDA5My0wLjQwMDYyLTEuNDA5Ny0wLjU5NzE4LTIuNDExMy0wLjU5NzE4em00NC4xNDYgMGMtMC44NTEzIDAtMS40NzIzIDAuMTk2NTYtMS44NDc4IDAuNTk3MTgtMC4zNTA1NSAwLjM3NTU4LTAuNTE4MyAxLjAyMjktMC41MTgzIDEuOTQ5M3YxMS45MWMwIDAuODc2MzMgMC4yMDUzMiAxLjUwNjEgMC42MzA5OCAxLjg4MTcgMC40MjU2NiAwLjM3NTU4IDEuMTU5MyAwLjU2MzM3IDIuMTg1OSAwLjU2MzM3czEuNzQ5LTAuMTg3NzkgMi4xNzQ3LTAuNTYzMzdjMC40MjU2OS0wLjM3NTU4IDAuNjQyMjYtMS4wMDUzIDAuNjQyMjYtMS44ODE3di05LjM1MTdoMTguODUxbC0yNC43NTQgMzUuMzAxYy0wLjM1MDU0IDAuNTI1ODItMC41MTgzMSAxLjA3MTctMC41MTgzMSAxLjYyMjYgMCAwLjkyNjQ1IDAuMTY3NzcgMS41ODI1IDAuNTE4MzEgMS45ODMxIDAuMzc1NTggMC4zNzU1OCAwLjk5NjU0IDAuNTYzMzggMS44NDc4IDAuNTYzMzhoMjguNzY2YzAuODUxMyAwIDEuNDUyMi0wLjE4NzggMS44MDI4LTAuNTYzMzggMC4zNzU1OC0wLjQwMDYyIDAuNTYzMzgtMS4wNTY2IDAuNTYzMzgtMS45ODMxdi0xMi42NjVjMC0wLjg3NjMzLTAuMjE2NTgtMS40OTQ4LTAuNjQyMjUtMS44NzA0LTAuNDI1NjYtMC4zNzU1OC0xLjE0OC0wLjU2MzM4LTIuMTc0Ny0wLjU2MzM4LTEuMDI2NiAwLTEuNzQ5IDAuMTg3NzktMi4xNzQ3IDAuNTYzMzgtMC40MjU2NiAwLjM3NTU4LTAuNjQyMjQgMC45OTQwMi0wLjY0MjI0IDEuODcwNHYxMC4xMDdoLTE5Ljk3OGwyNC45MDEtMzUuNDU5YzAuMjUwMzktMC4zNTA1NCAwLjM3MTgzLTAuODM4ODMgMC4zNzE4My0xLjQ2NDggMC0wLjkyNjQ1LTAuMTg3OC0xLjU3MzctMC41NjMzOC0xLjk0OTMtMC4zNTA1NS0wLjQwMDYyLTAuOTUxNDctMC41OTcxOC0xLjgwMjgtMC41OTcxOHptLTMxLjc1MiA1LjEwNDJoMC43MDk4NWw2Ljk4NTkgMjAuMzE1aC0xNC43MTZ6bS0zNy43MjMtNDkuMTgzYy00LjczNDIgMC04LjYzMTMgMy44OTctOC42MzEzIDguNjMxM3YxMTUuNDdjMCA0LjczNDIgMy44OTcgOC42MzEzIDguNjMxMyA4LjYzMTNoMTE1LjI2YzQuNzM0MiAwIDguNjQyMS0zLjg5NyA4LjY0MjEtOC42MzEzdi0xMTUuNDdjMC00LjczNDItMy45MDgyLTguNjMxMy04LjY0MjEtOC42MzEzem0wIDUuNzI0aDExNS4yNmMxLjY1OCAwIDIuOTA3IDEuMjQ5MSAyLjkwNyAyLjkwNzF2MTE1LjQ3YzAgMS42NTgtMS4yNDkxIDIuOTA3LTIuOTA3IDIuOTA3aC0xMTUuMjZjLTEuNjU4IDAtMi44OTU4LTEuMjQ5MS0yLjg5NTgtMi45MDd2LTExNS40N2MwLTEuNjU4IDEuMjM3OC0yLjkwNzEgMi44OTU4LTIuOTA3MXoiIGZpbGw9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIuNzIxMTQiLz48L3N2Zz4=');}.icon-scribble{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDQuMjUsMTg4LjI0YTE2LjYzLDE2LjYzLDAsMCwwLDAsMjMuNTIsNiw2LDAsMSwxLTguNDgsOC40OCwyOC42MSwyOC42MSwwLDAsMSwwLTQwLjQ4bDkuMzctOS4zOGExNi42MywxNi42MywwLDAsMC0yMy41Mi0yMy41MWwtNjYuNzUsNjYuNzVhMjguNjMsMjguNjMsMCwwLDEtNDAuNDktNDAuNDlsOTguNzYtOTguNzVhMTYuNjMsMTYuNjMsMCwwLDAtMjMuNTItMjMuNTFMODIuODYsMTE3LjYyQTI4LjYzLDI4LjYzLDAsMCwxLDQyLjM3LDc3LjEzTDgzLjc1LDM1Ljc2YTYsNiwwLDEsMSw4LjQ5LDguNDhMNTAuODYsODUuNjJhMTYuNjMsMTYuNjMsMCwwLDAsMjMuNTIsMjMuNTFsNjYuNzUtNjYuNzVhMjguNjMsMjguNjMsMCwwLDEsNDAuNDksNDAuNDlMODIuODYsMTgxLjYyYTE2LjYzLDE2LjYzLDAsMCwwLDIzLjUyLDIzLjUxbDY2Ljc2LTY2Ljc1YTI4LjYzLDI4LjYzLDAsMCwxLDQwLjQ5LDQwLjQ5WiIvPjwvc3ZnPg==');}.icon-brackets-angle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik04NS4wNiw0My4yMiwzMS4xMSwxMjhsNTQsODQuNzhhNiw2LDAsMCwxLTEuODQsOC4yOCw2LDYsMCwwLDEtOC4yOC0xLjg0bC01Ni04OGE2LDYsMCwwLDEsMC02LjQ0bDU2LTg4YTYsNiwwLDAsMSwxMC4xMiw2LjQ0Wm0xNTIsODEuNTYtNTYtODhhNiw2LDAsMSwwLTEwLjEyLDYuNDRMMjI0Ljg5LDEyOGwtNTMuOTUsODQuNzhhNiw2LDAsMCwwLDEuODQsOC4yOCw2LDYsMCwwLDAsOC4yOC0xLjg0bDU2LTg4QTYsNiwwLDAsMCwyMzcuMDYsMTI0Ljc4WiIvPjwvc3ZnPg==');}.icon-brain{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsMTI0YTU0LjEzLDU0LjEzLDAsMCwwLTMyLTQ5LjMzVjcyYTQ2LDQ2LDAsMCwwLTg2LTIyLjY3QTQ2LDQ2LDAsMCwwLDQyLDcydjIuNjdhNTQsNTQsMCwwLDAsMCw5OC42M1YxNzZhNDYsNDYsMCwwLDAsODYsMjIuNjdBNDYsNDYsMCwwLDAsMjE0LDE3NnYtMi43QTU0LjA3LDU0LjA3LDAsMCwwLDI0NiwxMjRaTTg4LDIxMGEzNCwzNCwwLDAsMS0zNC0zMi45NEE1My42Nyw1My42NywwLDAsMCw2NCwxNzhoOGE2LDYsMCwwLDAsMC0xMkg2NEE0Miw0MiwwLDAsMSw1MCw4NC4zOWE2LDYsMCwwLDAsNC01LjY2VjcyYTM0LDM0LDAsMCwxLDY4LDB2NzMuMDVBNDUuODksNDUuODksMCwwLDAsODgsMTMwYTYsNiwwLDAsMCwwLDEyLDM0LDM0LDAsMCwxLDAsNjhabTEwNC00NGgtOGE2LDYsMCwwLDAsMCwxMmg4YTUzLjY3LDUzLjY3LDAsMCwwLDEwLS45NEEzNCwzNCwwLDEsMSwxNjgsMTQyYTYsNiwwLDAsMCwwLTEyLDQ1Ljg5LDQ1Ljg5LDAsMCwwLTM0LDE1LjA1VjcyYTM0LDM0LDAsMCwxLDY4LDB2Ni43M2E2LDYsMCwwLDAsNCw1LjY2QTQyLDQyLDAsMCwxLDE5MiwxNjZabTE0LTU0YTYsNiwwLDAsMS02LDZoLTRhMzQsMzQsMCwwLDEtMzQtMzRWODBhNiw2LDAsMCwxLDEyLDB2NGEyMiwyMiwwLDAsMCwyMiwyMmg0QTYsNiwwLDAsMSwyMDYsMTEyWk02MCwxMThINTZhNiw2LDAsMCwxLDAtMTJoNEEyMiwyMiwwLDAsMCw4Miw4NFY4MGE2LDYsMCwwLDEsMTIsMHY0QTM0LDM0LDAsMCwxLDYwLDExOFoiLz48L3N2Zz4=');}.icon-palette{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xOTkuMzcsNTUuMzFBMTAxLjMyLDEwMS4zMiwwLDAsMCwxMjgsMjZoLTFBMTAyLDEwMiwwLDAsMCwyNiwxMjhjMCw0Mi4wOSwyNi4wNyw3Ny40NCw2OCw5Mi4yNkEzMC4yMSwzMC4yMSwwLDAsMCwxMDQuMTEsMjIyLDMwLjA2LDMwLjA2LDAsMCwwLDEzNCwxOTJhMTgsMTgsMCwwLDEsMTgtMThoNDYuMjFhMjkuODIsMjkuODIsMCwwLDAsMjkuMjUtMjMuMzFBMTAyLjcxLDEwMi43MSwwLDAsMCwyMzAsMTI3LjExLDEwMS4yNSwxMDEuMjUsMCwwLDAsMTk5LjM3LDU1LjMxWk0yMTUuNzYsMTQ4YTE3Ljg5LDE3Ljg5LDAsMCwxLTE3LjU1LDE0SDE1MmEzMCwzMCwwLDAsMC0zMCwzMCwxOCwxOCwwLDAsMS0yNCwxN0M2MSwxOTUuODYsMzgsMTY0Ljg1LDM4LDEyOGE5MCw5MCwwLDAsMSw4OS4wNy05MEgxMjhhOTAuMzQsOTAuMzQsMCwwLDEsOTAsODkuMjJBOTAuNDYsOTAuNDYsMCwwLDEsMjE1Ljc2LDE0OFpNMTM4LDc2YTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDEzOCw3NlpNOTQsMTAwQTEwLDEwLDAsMSwxLDg0LDkwLDEwLDEwLDAsMCwxLDk0LDEwMFptMCw1NmExMCwxMCwwLDEsMS0xMC0xMEExMCwxMCwwLDAsMSw5NCwxNTZabTg4LTU2YTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDE4MiwxMDBaIi8+PC9zdmc+');}.icon-pen-nib{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsOTIuNjhhMTMuOTQsMTMuOTQsMCwwLDAtNC4xLTkuOUwxNzMuMjEsMTQuMWExNCwxNCwwLDAsMC0xOS44LDBMMTI0LjY4LDQyLjgzLDY2LjIyLDY0Ljc2YTE0LDE0LDAsMCwwLTguOSwxMC44TDM0LjA4LDIxNUE2LDYsMCwwLDAsNDAsMjIyYTYuNjEsNi42MSwwLDAsMCwxLS4wOGwxMzkuNDQtMjMuMjRhMTQsMTQsMCwwLDAsMTAuODEtOC45bDIxLjkyLTU4LjQ2LDI4Ljc0LTI4Ljc0QTEzLjkyLDEzLjkyLDAsMCwwLDI0Niw5Mi42OFptLTY2LDkyLjg5YTIsMiwwLDAsMS0xLjU0LDEuMjdMNTcuNDksMjA3bDUyLjg3LTUyLjg4YTI2LDI2LDAsMSwwLTguNDgtOC40OEw0OSwxOTguNTNsMjAuMTctMTIxQTIsMiwwLDAsMSw3MC40Myw3Nmw1Ni4wNi0yMUwyMDEsMTI5LjUxWk0xMTAsMTMyYTE0LDE0LDAsMSwxLDE0LDE0QTE0LDE0LDAsMCwxLDExMCwxMzJaTTIzMy40MSw5NC4xLDIwOCwxMTkuNTEsMTM2LjQ4LDQ4LDE2MS45LDIyLjU4YTIsMiwwLDAsMSwyLjgzLDBsNjguNjgsNjguNjlhMiwyLDAsMCwxLDAsMi44M1oiLz48L3N2Zz4=');}.icon-question{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMzgsMTgwYTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDEzOCwxODBaTTEyOCw3NGMtMjEsMC0zOCwxNS4yNS0zOCwzNHY0YTYsNiwwLDAsMCwxMiwwdi00YzAtMTIuMTMsMTEuNjYtMjIsMjYtMjJzMjYsOS44NywyNiwyMi0xMS42NiwyMi0yNiwyMmE2LDYsMCwwLDAtNiw2djhhNiw2LDAsMCwwLDEyLDB2LTIuNDJjMTguMTEtMi41OCwzMi0xNi42NiwzMi0zMy41OEMxNjYsODkuMjUsMTQ5LDc0LDEyOCw3NFptMTAyLDU0QTEwMiwxMDIsMCwxLDEsMTI4LDI2LDEwMi4xMiwxMDIuMTIsMCwwLDEsMjMwLDEyOFptLTEyLDBhOTAsOTAsMCwxLDAtOTAsOTBBOTAuMSw5MC4xLDAsMCwwLDIxOCwxMjhaIi8+PC9zdmc+');}.icon-city{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDAsMjEwSDIzMFY4OGE2LDYsMCwwLDAtNi02SDE2MGE2LDYsMCwwLDAtNiw2djQySDEwMlY0MGE2LDYsMCwwLDAtNi02SDMyYTYsNiwwLDAsMC02LDZWMjEwSDE2YTYsNiwwLDAsMCwwLDEySDI0MGE2LDYsMCwwLDAsMC0xMlpNMTY2LDk0aDUyVjIxMEgxNjZabS0xMiw0OHY2OEgxMDJWMTQyWk0zOCw0Nkg5MFYyMTBIMzhaTTcwLDcyVjg4YTYsNiwwLDAsMS0xMiwwVjcyYTYsNiwwLDAsMSwxMiwwWm0wLDQ4djE2YTYsNiwwLDAsMS0xMiwwVjEyMGE2LDYsMCwwLDEsMTIsMFptMCw0OHYxNmE2LDYsMCwwLDEtMTIsMFYxNjhhNiw2LDAsMCwxLDEyLDBabTUyLDE2VjE2OGE2LDYsMCwwLDEsMTIsMHYxNmE2LDYsMCwwLDEtMTIsMFptNjQsMFYxNjhhNiw2LDAsMCwxLDEyLDB2MTZhNiw2LDAsMCwxLTEyLDBabTAtNDhWMTIwYTYsNiwwLDAsMSwxMiwwdjE2YTYsNiwwLDAsMS0xMiwwWiIvPjwvc3ZnPg==');}.icon-folder{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTYsNzRIMTMwLjQ5bC0yNy45LTI3LjlhMTMuOTQsMTMuOTQsMCwwLDAtOS45LTQuMUg0MEExNCwxNCwwLDAsMCwyNiw1NlYyMDAuNjJBMTMuMzksMTMuMzksMCwwLDAsMzkuMzgsMjE0SDIxNi44OUExMy4xMiwxMy4xMiwwLDAsMCwyMzAsMjAwLjg5Vjg4QTE0LDE0LDAsMCwwLDIxNiw3NFpNNDAsNTRIOTIuNjlhMiwyLDAsMCwxLDEuNDEuNTlMMTEzLjUxLDc0SDM4VjU2QTIsMiwwLDAsMSw0MCw1NFpNMjE4LDIwMC44OWExLjExLDEuMTEsMCwwLDEtMS4xMSwxLjExSDM5LjM4QTEuNCwxLjQsMCwwLDEsMzgsMjAwLjYyVjg2SDIxNmEyLDIsMCwwLDEsMiwyWiIvPjwvc3ZnPg==');}.icon-hash{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjQsOTBIMTczbDguODktNDguOTNhNiw2LDAsMSwwLTExLjgtMi4xNEwxNjAuODEsOTBIMTA5bDguODktNDguOTNhNiw2LDAsMCwwLTExLjgtMi4xNEw5Ni44MSw5MEg0OGE2LDYsMCwwLDAsMCwxMkg5NC42M2wtOS40Niw1MkgzMmE2LDYsMCwwLDAsMCwxMkg4M0w3NC4xLDIxNC45M2E2LDYsMCwwLDAsNC44Myw3QTUuNjQsNS42NCwwLDAsMCw4MCwyMjJhNiw2LDAsMCwwLDUuODktNC45M0w5NS4xOSwxNjZIMTQ3bC04Ljg5LDQ4LjkzYTYsNiwwLDAsMCw0LjgzLDcsNS42NCw1LjY0LDAsMCwwLDEuMDguMSw2LDYsMCwwLDAsNS44OS00LjkzTDE1OS4xOSwxNjZIMjA4YTYsNiwwLDAsMCwwLTEySDE2MS4zN2w5LjQ2LTUySDIyNGE2LDYsMCwwLDAsMC0xMlptLTc0LjgzLDY0SDk3LjM3bDkuNDYtNTJoNTEuOFoiLz48L3N2Zz4=');}.icon-shapes{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik02OS42OSw2Mi4xYTYsNiwwLDAsMC0xMS4zOCwwbC00MCwxMjBBNiw2LDAsMCwwLDI0LDE5MGg4MGE2LDYsMCwwLDAsNS42OS03LjlaTTMyLjMyLDE3OCw2NCw4M2wzMS42OCw5NVpNMjA2LDc2YTUwLDUwLDAsMSwwLTUwLDUwQTUwLjA2LDUwLjA2LDAsMCwwLDIwNiw3NlptLTg4LDBhMzgsMzgsMCwxLDEsMzgsMzhBMzgsMzgsMCwwLDEsMTE4LDc2Wm0xMDYsNzBIMTM2YTYsNiwwLDAsMC02LDZ2NTZhNiw2LDAsMCwwLDYsNmg4OGE2LDYsMCwwLDAsNi02VjE1MkE2LDYsMCwwLDAsMjI0LDE0NlptLTYsNTZIMTQyVjE1OGg3NloiLz48L3N2Zz4=');}.icon-diamonds-four{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjMuNzYsMTA4LjI0YTYsNiwwLDAsMCw4LjQ4LDBsNDAtNDBhNiw2LDAsMCwwLDAtOC40OGwtNDAtNDBhNiw2LDAsMCwwLTguNDgsMGwtNDAsNDBhNiw2LDAsMCwwLDAsOC40OFpNMTI4LDMyLjQ5LDE1OS41MSw2NCwxMjgsOTUuNTEsOTYuNDksNjRabTQuMjQsMTE1LjI3YTYsNiwwLDAsMC04LjQ4LDBsLTQwLDQwYTYsNiwwLDAsMCwwLDguNDhsNDAsNDBhNiw2LDAsMCwwLDguNDgsMGw0MC00MGE2LDYsMCwwLDAsMC04LjQ4Wk0xMjgsMjIzLjUxLDk2LjQ5LDE5MiwxMjgsMTYwLjQ5LDE1OS41MSwxOTJabTEwOC4yNC05OS43NS00MC00MGE2LDYsMCwwLDAtOC40OCwwbC00MCw0MGE2LDYsMCwwLDAsMCw4LjQ4bDQwLDQwYTYsNiwwLDAsMCw4LjQ4LDBsNDAtNDBBNiw2LDAsMCwwLDIzNi4yNCwxMjMuNzZaTTE5MiwxNTkuNTEsMTYwLjQ5LDEyOCwxOTIsOTYuNDksMjIzLjUxLDEyOFptLTgzLjc2LTM1Ljc1LTQwLTQwYTYsNiwwLDAsMC04LjQ4LDBsLTQwLDQwYTYsNiwwLDAsMCwwLDguNDhsNDAsNDBhNiw2LDAsMCwwLDguNDgsMGw0MC00MEE2LDYsMCwwLDAsMTA4LjI0LDEyMy43NlpNNjQsMTU5LjUxLDMyLjQ5LDEyOCw2NCw5Ni40OSw5NS41MSwxMjhaIi8+PC9zdmc+');}.icon-crosshair-simple{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjgsMjZBMTAyLDEwMiwwLDEsMCwyMzAsMTI4LDEwMi4xMiwxMDIuMTIsMCwwLDAsMTI4LDI2Wm02LDE5MS44VjE4NGE2LDYsMCwwLDAtMTIsMHYzMy44QTkwLjE1LDkwLjE1LDAsMCwxLDM4LjIsMTM0SDcyYTYsNiwwLDAsMCwwLTEySDM4LjJBOTAuMTUsOTAuMTUsMCwwLDEsMTIyLDM4LjJWNzJhNiw2LDAsMCwwLDEyLDBWMzguMkE5MC4xNSw5MC4xNSwwLDAsMSwyMTcuOCwxMjJIMTg0YTYsNiwwLDAsMCwwLDEyaDMzLjhBOTAuMTUsOTAuMTUsMCwwLDEsMTM0LDIxNy44WiIvPjwvc3ZnPg==');}.icon-circle-notch{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzAsMTI4YTEwMiwxMDIsMCwwLDEtMjA0LDBjMC00MC4xOCwyMy4zNS03Ni44Niw1OS41LTkzLjQ1YTYsNiwwLDAsMSw1LDEwLjlDNTguNjEsNjAuMDksMzgsOTIuNDksMzgsMTI4YTkwLDkwLDAsMCwwLDE4MCwwYzAtMzUuNTEtMjAuNjEtNjcuOTEtNTIuNS04Mi41NWE2LDYsMCwwLDEsNS0xMC45QzIwNi42NSw1MS4xNCwyMzAsODcuODIsMjMwLDEyOFoiLz48L3N2Zz4=');}.icon-cards-three{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsOTBINDhhMTQsMTQsMCwwLDAtMTQsMTR2OTZhMTQsMTQsMCwwLDAsMTQsMTRIMjA4YTE0LDE0LDAsMCwwLDE0LTE0VjEwNEExNCwxNCwwLDAsMCwyMDgsOTBabTIsMTEwYTIsMiwwLDAsMS0yLDJINDhhMiwyLDAsMCwxLTItMlYxMDRhMiwyLDAsMCwxLDItMkgyMDhhMiwyLDAsMCwxLDIsMlpNNTAsNjRhNiw2LDAsMCwxLDYtNkgyMDBhNiw2LDAsMCwxLDAsMTJINTZBNiw2LDAsMCwxLDUwLDY0Wk02NiwzMmE2LDYsMCwwLDEsNi02SDE4NGE2LDYsMCwwLDEsMCwxMkg3MkE2LDYsMCwwLDEsNjYsMzJaIi8+PC9zdmc+');}.icon-logo{--icon:url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyAgICB3aWR0aD0iMzIiICAgIGhlaWdodD0iMzIiICAgIHZpZXdCb3g9IjAgMCAzMiAzMiIgICAgdmVyc2lvbj0iMS4xIiAgICB4bWw6c3BhY2U9InByZXNlcnZlIiAgICBzdHlsZT0iY2xpcC1ydWxlOmV2ZW5vZGQ7ZmlsbC1ydWxlOmV2ZW5vZGQ7c3Ryb2tlLWxpbmVjYXA6c3F1YXJlO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDoxLjUiICAgIGlkPSJzdmcxNCIgICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiAgICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcyAgICBpZD0iZGVmczE0IiAvPjxwYXRoICAgIGQ9Ik0gMTYuNTgwMDc4LDIuMTMyODEyNSBDIDguODY0ODQ0OSwyLjEzMjgxMjUgMS40NDA2MDIxLDguMTQ2NjIxOCAyLjAzMzIwMzEsMTUuODM5ODQ0IDIuNTk0NDU4OCwyMy4xMjY2NjYgOC45NzEyMDEyLDI5LjAyNTE1NSAxNS45NzA3MDMsMjkuNzg3MTA5IDI0LjIyMTIyNCwzMC42ODUyNCAzMC40NDA5MTEsMjMuODM0Mjc3IDI5Ljk3NDYwOSwxNS43OTg4MjggMjkuNTI3ODEzLDguMDk5ODY1NSAyNC4yOTE1NiwyLjEzMjgxMjUgMTYuNTgwMDc4LDIuMTMyODEyNSBaIG0gMCwwLjYwNzQyMTkgYyAwLjAxMDQ2LDAgMC4wMjA4MywwIDAuMDMxMjUsMCBWIDI5LjIzMjQyMiBjIC0wLjE5MDMyMywtMC4wMTIxOCAtMC4zODE1MjEsLTAuMDI3ODMgLTAuNTc0MjE5LC0wLjA0ODgzIEMgOS4zMTMwNDUzLDI4LjQ1MTYxNSAzLjE3Nzg3NzUsMjIuNzkzMDQ0IDIuNjM4NjcxOSwxNS43OTI5NjkgMi4wNzI0NTYsOC40NDIwMTUzIDkuMjA4MTAwOCwyLjc0MDIzNDQgMTYuNTgwMDc4LDIuNzQwMjM0NCBaIE0gMTYuMDkxNzk3LDMuODg0NzY1NiAxNiwzLjg4ODY3MTkgQyAxMi43MjU0NTQsNC4wMTgzNDg5IDkuNTUyMzM3OSw1LjM2NDY4MzggNy4yNTU4NTk0LDcuNSA0Ljk1OTM4MDksOS42MzUzMTYyIDMuNTQwMjcwMywxMi41NjQ5NzIgMy43OTI5Njg4LDE1Ljg0NTcwMyA0LjI4NDc3MzksMjIuMjMwMDQ1IDkuODY0NDgxMiwyNy4zODM2MDYgMTUuOTk0MTQxLDI4LjA1MjczNCBsIDAuMDg5ODQsMC4wMDk4IDAuMDIxNDgsLTAuMTgxNjQxIC0wLjA5MTgsLTAuMDA5OCBDIDkuOTcyNjc0OSwyNy4yMTE2NDQgNC40NTg4NjkxLDIyLjExNjQ2OCAzLjk3NDYwOTQsMTUuODMwMDc4IDMuNzI2NTU1OSwxMi42MDk2NTEgNS4xMTU4MDg0LDkuNzM5MDQzNyA3LjM3ODkwNjIsNy42MzQ3NjU2IDkuNjQyMDA0MSw1LjUzMDQ4NzUgMTIuNzc4NTM5LDQuMTk4MTk2OCAxNi4wMDc4MTIsNC4wNzAzMTI1IGwgMC4wOTE4LC0wLjAwMzkxIHogbSAwLDEuNTAxOTUzMSBMIDE2LDUuMzkwNjI1IEMgMTMuMTMxOTQ5LDUuNTA0MjA0NyAxMC4zNTMyOTgsNi42ODQzNDE1IDguMzQxNzk2OSw4LjU1NDY4NzUgNi4zMzAyOTYyLDEwLjQyNTAzMyA1LjA4NzE5MjksMTIuOTkwODE5IDUuMzA4NTkzNywxNS44NjUyMzQgNS43MzkzOTQsMjEuNDU3NjY5IDEwLjYyNTE2MSwyNS45NzA1NDcgMTUuOTk0MTQxLDI2LjU1NjY0MSBsIDAuMDg5ODQsMC4wMDk4IDAuMDIxNDgsLTAuMTc5Njg3IC0wLjA5MTgsLTAuMDA5OCBDIDEwLjczMzM1NCwyNS44MDA1MzMgNS45MTM0ODkyLDIxLjM0NjA0NiA1LjQ5MDIzNDQsMTUuODUxNTYzIDUuMjczNDc4NCwxMy4wMzc0NTEgNi40ODY3MjM3LDEwLjUyNjgwOCA4LjQ2NDg0MzgsOC42ODc1IDEwLjQ0Mjk2NCw2Ljg0ODE5MjIgMTMuMTg1MDMyLDUuNjg0MDUyNiAxNi4wMDc4MTIsNS41NzIyNjU2IGwgMC4wOTE4LC0wLjAwMzkxIHogbSAwLDEuNTAzOTA2MyBMIDE2LDYuODkyNTc4MSBjIC0yLjQ2MTU3NywwLjA5NzQ4MyAtNC44NDU3MjgsMS4xMTE0MTc0IC02LjU3MjI2NTYsMi43MTY3OTY5IC0xLjcyNjUzOCwxLjYwNTM4IC0yLjc5NTU3NDEsMy44MDcyODIgLTIuNjA1NDY4OCw2LjI3NTM5MSAwLjM2OTc5ODYsNC44MDA1NCA0LjU2MzUzMzQsOC42NzQ2NzQgOS4xNzE4NzU0LDkuMTc3NzM0IGwgMC4wODk4NCwwLjAwOTggMC4wMjE0OCwtMC4xODE2NDEgLTAuMDkxOCwtMC4wMDk4IEMgMTEuNDk0MDM3LDI0LjM4NzQ4MSA3LjM2NjE1NTcsMjAuNTczNjM0IDcuMDAzOTA2MiwxNS44NzEwOTQgNi44MTg0NDgxLDEzLjQ2MzMyIDcuODU3NjQwNSwxMS4zMTY1MTMgOS41NTA3ODEzLDkuNzQyMTg3NSAxMS4yNDM5MjIsOC4xNjc4NjE4IDEzLjU5MTUyOSw3LjE3MTg2MDggMTYuMDA3ODEyLDcuMDc2MTcxOSBsIDAuMDkxOCwtMC4wMDM5MSB6IG0gMCwxLjUwMTk1MzEgTCAxNiw4LjM5NjQ4NDQgYyAtMi4wNTUwNzMsMC4wODEzODQgLTQuMDQ0Nzc1LDAuOTI1MjMzNCAtNS40ODYzMjgsMi4yNjU2MjQ2IC0xLjQ0MTU1MzUsMS4zNDAzOTMgLTIuMzM0NTg4MSwzLjE4MjM3OSAtMi4xNzU3ODE0LDUuMjQ0MTQxIDAuMzA4NzkyLDQuMDA4NTc5IDMuODA4NjA1NCw3LjI0MDEzNiA3LjY1NjI1MDQsNy42NjAxNTYgbCAwLjA4OTg0LDAuMDA5OCAwLjAwNzgsLTAuMDY4MzYgdiAtMC4wMDIgbCAwLjAwMiwtMC4wMDk4IGMgOS40OWUtNCwtMC4wMDM0IDAuMDAzNSwtMC4wMDYyIDAuMDAzOSwtMC4wMDk4IDYuNDJlLTQsLTAuMDA2NyA4LjAyZS00LC0wLjAxMzAxIDAsLTAuMDE5NTMgbCAwLjAwNzgsLTAuMDcyMjcgLTAuMDkxOCwtMC4wMDk4IEMgMTIuMjU0NjY3LDIyLjk3NDQyMiA4LjgyMDc3OTYsMTkuODAzMjMxIDguNTE5NTMxMywxNS44OTI1NzggOC4zNjUzNjgyLDEzLjg5MTEwNSA5LjIyODUzNzMsMTIuMTA2MjM3IDEwLjYzNjcxOSwxMC43OTY4NzUgMTIuMDQ0OSw5LjQ4NzUxMyAxMy45OTc5OTksOC42NTc3MTcgMTYuMDA3ODEyLDguNTc4MTI1IGwgMC4wOTE4LC0wLjAwMzkxIHogbSAwLDEuNTAxOTUzMiBMIDE2LDkuODk4NDM3NSBjIC0xLjY0ODU4OCwwLjA2NTI4NyAtMy4yNDU3NjEsMC43NDI5Mzg1IC00LjQwMjM0NCwxLjgxODM1OTUgLTEuMTU2NTgyLDEuMDc1NDIxIC0xLjg3MTY1MDYsMi41NTM1MzggLTEuNzQ0MTQwNCw0LjIwODk4NCAwLjI0Nzc4ODQsMy4yMTY2NjkgMy4wNTM2NDE0LDUuODA5NTAxIDYuMTQwNjI1NCw2LjE0NjQ4NSBsIDAuMDg5ODQsMC4wMDk4IDAuMDIxNDgsLTAuMTgxNjQgLTAuMDkxOCwtMC4wMDk4IGMgLTIuOTk4MzQ0LC0wLjMyNzMwOCAtNS43MzgyNzMsLTIuODU5Nzk4IC01Ljk3ODUxNiwtNS45Nzg1MTYgLTAuMTIyODY0NSwtMS41OTUxNDIgMC41NjQyOTgsLTMuMDE4MTE3IDEuNjg3NSwtNC4wNjI1IDEuMTIzMjAyLC0xLjA0NDM4MiAyLjY4MTgzOSwtMS43MDYwMzcgNC4yODUxNTYsLTEuNzY5NTMxIGwgMC4wOTE4LC0wLjAwMzkgeiBtIDAsMS41MDM5MDY3IC0wLjA5MTgsMC4wMDIgYyAtMS4yNDIwOTUsMC4wNDkxOSAtMi40NDQ4LDAuNTYwNjUyIC0zLjMxNjQwNiwxLjM3MTA5MyAtMC44NzE2MDYsMC44MTA0NDIgLTEuNDEyNjE5LDEuOTI0NzEzIC0xLjMxNjQwNiwzLjE3MzgyOSAwLjE4Njc4MywyLjQyNDczMiAyLjMwMDY0Myw0LjM3NjkxMyA0LjYyNjk1Myw0LjYzMDg1OSBsIDAuMDg5ODQsMC4wMDk4IDAuMDIxNDgsLTAuMTgxNjQgLTAuMDkxOCwtMC4wMDk4IGMgLTIuMjM3NjkyLC0wLjI0NDI3MiAtNC4yODU2MDQsLTIuMTM2MDgzIC00LjQ2NDg0NCwtNC40NjI4OSAtMC4wOTE1NywtMS4xODg4MjYgMC40MjE1MzIsLTIuMjQ3OTMzIDEuMjU5NzY2LC0zLjAyNzM0NCAwLjgzODIzNCwtMC43Nzk0MTEgMi4wMDIzODMsLTEuMjcyOTE2IDMuMTk5MjE4LC0xLjMyMDMxMyBsIDAuMDkxOCwtMC4wMDM5IHogbSAwLDEuNTAxOTUzIC0wLjA5MTgsMC4wMDM5IGMgLTAuODM1NjEzLDAuMDMzMDkgLTEuNjQzODMxLDAuMzc0NDUyIC0yLjIzMDQ2OSwwLjkxOTkyMiAtMC41ODY2MzcsMC41NDU0NyAtMC45NTE2MzYsMS4yOTk3NzggLTAuODg2NzE4LDIuMTQyNTc4IDAuMTI1NzgsMS42MzI4MjEgMS41NDU2NzMsMi45NDIzNyAzLjExMTMyOCwzLjExMzI4MSAtMC4wMDI2LC0yLjhlLTQgLTAuMDA1MywyLjg2ZS00IC0wLjAwNzgsMCAwLjAwMyw2LjAzZS00IDAuMDA2NiwwLjAwMTcgMC4wMDk4LDAuMDAyIDAuMDAzMSwzLjA4ZS00IDAuMDA2NywxLjJlLTUgMC4wMDk4LDAgbCAwLjA3ODEzLDAuMDA5OCAwLjAyMTQ4LC0wLjE4MTY0MSAtMC4wOTE4LC0wLjAwOTggYyAtMS40NzcwMTUsLTAuMTYxMjM1IC0yLjgzMDk4NCwtMS40MTIzOTYgLTIuOTQ5MjE5LC0yLjk0NzI2NiAtMC4wNjAyNywtMC43ODI0OTYgMC4yNzY4MjIsLTEuNDc5NzA5IDAuODMwMDc4LC0xLjk5NDE0MSAwLjU1MzI1NywtMC41MTQ0MzEgMS4zMjI5MzksLTAuODQxNzQ4IDIuMTEzMjgxLC0wLjg3MzA0NiBsIDAuMDkxOCwtMC4wMDM5IHogbSAwLDEuNTAxOTUzIEwgMTYsMTQuNDA2MjUgYyAtMC40MjkxMTcsMC4wMTY5OSAtMC44NDI4NzMsMC4xOTIxNjYgLTEuMTQ0NTMxLDAuNDcyNjU2IC0wLjMwMTY1OSwwLjI4MDQ5MSAtMC40OTA2NDgsMC42NzA5NTUgLTAuNDU3MDMxLDEuMTA3NDIyIDAuMDY0NzcsMC44NDA4ODYgMC43OTA3MjksMS41MTE3MzIgMS41OTU3MDMsMS41OTk2MDkgbCAwLjA4OTg0LDAuMDA5OCAwLjAyMTQ4LC0wLjE4MTY0MSAtMC4wOTE4LC0wLjAwOTggYyAtMC43MTYzNTcsLTAuMDc4MiAtMS4zNzYzNjIsLTAuNjg4NjgxIC0xLjQzMzU5NCwtMS40MzE2NDEgLTAuMDI4OTcsLTAuMzc2MTc5IDAuMTMyMTA1LC0wLjcxMTQ3NyAwLjQwMDM5MSwtMC45NjA5MzcgMC4yNjgyODYsLTAuMjQ5NDYxIDAuNjQzNDg1LC0wLjQwODYyNyAxLjAyNzM0MywtMC40MjM4MjggbCAwLjA5MTgsLTAuMDAzOSB6IiAgICBzdHlsZT0iYmFzZWxpbmUtc2hpZnQ6YmFzZWxpbmU7Y2xpcC1ydWxlOm5vbnplcm87ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTt2ZWN0b3ItZWZmZWN0Om5vbmU7ZmlsbDojMjIyMjIyO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGU7c3RvcC1jb2xvcjojMDAwMDAwO3N0b3Atb3BhY2l0eToxIiAgICBpZD0icGF0aDI3IiAvPjwvc3ZnPg==');}.icon-jakevan{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHN0eWxlPSJjbGlwLXJ1bGU6ZXZlbm9kZDtmaWxsLXJ1bGU6ZXZlbm9kZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLW1pdGVybGltaXQ6MiIgdmlld0JveD0iMCAwIDMyIDMyIj48cGF0aCBkPSJNMTcuODggMTQuNjhIMTIuOWwtLjQzLTEuNjNIOS41OGwtLjQ1IDEuNjNINi41bDIuODktOC43NGgzLjJsMi44OSA4LjY0di04LjZoMi40djMuNzhjLjEtLjIuMjItLjM4LjM1LS41Ny4xMy0uMi4yNi0uMzcuMzktLjU0bDEuODYtMi42N2g3Ljh2MS44OUgyNS40djEuMzdoMi42NXYxLjg4SDI1LjR2MS42NWgyLjg2djEuOTFoLTcuOTNsLTEuNzUtMy4zMi0uNy40MXptNS4xMy04LjU5LTIuNyAzLjc5IDIuNyA0Ljc0em0tMTEuMDUgNS4wMy0uMzgtMS40M2ExMzYuODYgMTM2Ljg2IDAgMCAwLS40LTEuNTVMMTEgNy4zOGExNy43NiAxNy43NiAwIDAgMS0uMzYgMS42bC0uMTguNzEtLjM5IDEuNDN6bS04LjU4IDYuM2E1Ljc0IDUuNzQgMCAwIDEtMS4yNC0uMTN2LTEuODNsLjQxLjA4Yy4xNS4wMy4zLjA1LjQ3LjA1LjMgMCAuNTEtLjA2LjY3LS4xN2EuOTIuOTIgMCAwIDAgLjM0LS41MmMuMDYtLjIzLjEtLjUyLjEtLjg2VjUuOThoMi40djcuODVjMCAuODgtLjEzIDEuNTctLjQgMi4xLS4yNi41Mi0uNjMuOS0xLjEgMS4xNC0uNDguMjMtMS4wMy4zNS0xLjY1LjM1WiIgc3R5bGU9ImZpbGw6Y3VycmVudENvbG9yO3N0cm9rZS13aWR0aDouMDE4NDM5MiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMS40IC42Nikgc2NhbGUoLjk2MDUwMTM0KSIvPjxwYXRoIGQ9Ik0yMi44MiAyMi4yN2gtNC4wNmwtLjM3LTEuNEgxNS45bC0uMzkgMS40aC0yLjI2bDIuNDktNy41M2gyLjc1bDIuNDkgNy40NHYtNy40MWgyLjdsMi43NyA1LjIxaC4wM2E0MS4xIDQxLjEgMCAwIDEtLjA3LTEuODJ2LTMuMzloMS44M3Y3LjVoLTIuN2wtMi43OS01LjI4aC0uMDRhMTIuODMgMTIuODMgMCAwIDEgLjA4IDEuMjZsLjAyLjY0em0tNC44Ni0zLjA3LS4zMy0xLjIzYTg5LjA3IDg5LjA3IDAgMCAwLS4zNS0xLjM0bC0uMTQtLjY1YTE1LjA0IDE1LjA0IDAgMCAxLS4zMSAxLjM3bC0uMTYuNjItLjMzIDEuMjN6bS0zLjg1LTQuNDMtMi41IDcuNUg5LjJsLTIuNS03LjVoMi4zMmwxLjA0IDMuOGExNS4wMyAxNS4wMyAwIDAgMSAuMzYgMS43NiA3LjYxIDcuNjEgMCAwIDEgLjItMS4ybC4xNC0uNTQgMS4wNi0zLjgyeiIgc3R5bGU9ImZpbGw6Y3VycmVudENvbG9yO3N0cm9rZS13aWR0aDouMDE1OTg4NCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMS40IC42Nikgc2NhbGUoLjk2MDUwMTM0KSIvPjxwYXRoIGQ9Ik0xMS45IDI0LjIxYzAgLjQtLjA3LjcyLS4yLjk5LS4xNS4yNi0uMzYuNDYtLjYzLjYtLjI4LjEzLS42Mi4yLTEuMDMuMkg5LjJ2LTMuNWguOTdjLjM4IDAgLjcuMDYuOTYuMTkuMjUuMTMuNDUuMzIuNTguNTguMTQuMjUuMi41Ny4yLjk0em0tLjI2LjAxYzAtLjMzLS4wNS0uNjEtLjE2LS44M2ExLjEgMS4xIDAgMCAwLS41MS0uNTEgMS45NSAxLjk1IDAgMCAwLS44Ny0uMTdoLS42NnYzLjA3aC42Yy41MyAwIC45My0uMTMgMS4yLS4zOS4yNy0uMjYuNC0uNjUuNC0xLjE3ek0xNC4yNyAyNmgtMS45NXYtMy41aDEuOTV2LjIyaC0xLjd2MS4zMmgxLjZ2LjIzaC0xLjZ2MS41aDEuN3ptMS4yOC0zLjVjLjI4IDAgLjUyLjAyLjcuMDhhLjguOCAwIDAgMSAuNDQuM2MuMS4xNC4xNC4zMy4xNC41N2EuOS45IDAgMCAxLS4xLjQ1Ljg3Ljg3IDAgMCAxLS4yNy4zMmMtLjEyLjA4LS4yNS4xNC0uNC4xOGwuOTggMS42aC0uM2wtLjkyLTEuNTNoLS44OVYyNmgtLjI1di0zLjV6bS0uMDMuMjFoLS41OXYxLjU1aC43MWMuMyAwIC41Mi0uMDcuNjktLjIxLjE2LS4xNC4yNC0uMzQuMjQtLjYgMC0uMjgtLjA5LS40Ny0uMjYtLjU4LS4xNy0uMS0uNDMtLjE2LS43OS0uMTZ6bTUuNTctLjIyTDIwLjEyIDI2aC0uMjVsLS43Ni0yLjY1LS4wNS0uMTYtLjA0LS4xNGExOC44IDE4LjggMCAwIDEtLjA2LS4yNCAyMC42IDIwLjYgMCAwIDEtLjExLjQ4TDE4LjA5IDI2aC0uMjVsLS45Ni0zLjVoLjI2bC42NyAyLjQ3YTI3LjM2IDI3LjM2IDAgMCAxIC4wOS4zNWwuMDQuMTcuMDMuMTUuMDMtLjE2YTQuODMgNC44MyAwIDAgMSAuMTQtLjUzbC43LTIuNDZoLjI1bC43MyAyLjQ4YTExLjk4IDExLjk4IDAgMCAxIC4xMy41M2wuMDQuMTVhMTEuMDIgMTEuMDIgMCAwIDEgLjE1LS42OGwuNjktMi40OHpNMjMuMjYgMjZoLTEuOTV2LTMuNWgxLjk1di4yMmgtMS43djEuMzJoMS42di4yM2gtMS42djEuNWgxLjd6bTEuMjgtMy41Yy4yOCAwIC41Mi4wMi43MS4wOGEuOC44IDAgMCAxIC40My4zYy4xLjE0LjE0LjMzLjE0LjU3YS45LjkgMCAwIDEtLjEuNDUuODcuODcgMCAwIDEtLjI3LjMyYy0uMTEuMDgtLjI1LjE0LS40LjE4bC45OCAxLjZoLS4zbC0uOTItMS41M2gtLjg4VjI2aC0uMjZ2LTMuNXptLS4wMi4yMWgtLjZ2MS41NWguNzJjLjI5IDAgLjUxLS4wNy42OC0uMjEuMTYtLjE0LjI0LS4zNC4yNC0uNiAwLS4yOC0uMDgtLjQ3LS4yNi0uNTgtLjE3LS4xLS40My0uMTYtLjc4LS4xNnpNMjYuNSAyNmgtLjI1di0zLjVoMS45NXYuMjJoLTEuN3YxLjQ5aDEuNnYuMjJoLTEuNnoiIHN0eWxlPSJmaWxsOmN1cnJlbnRDb2xvcjtzdHJva2Utd2lkdGg6LjAxMDEwNjgiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDEuNCAuNjYpIHNjYWxlKC45NjA1MDEzNCkiLz48L3N2Zz4=');}.icon-user-square{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMzRINDhBMTQsMTQsMCwwLDAsMzQsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFY0OEExNCwxNCwwLDAsMCwyMDgsMzRaTTk0LDEyMGEzNCwzNCwwLDEsMSwzNCwzNEEzNCwzNCwwLDAsMSw5NCwxMjBaTTY1Ljc3LDIxMGE2Ni40Myw2Ni40MywwLDAsMSwyMC43Ny0yOS4zNiw2Niw2NiwwLDAsMSw4Mi45MiwwQTY2LjQzLDY2LjQzLDAsMCwxLDE5MC4yMywyMTBaTTIxMCwyMDhhMiwyLDAsMCwxLTIsMmgtNS4xN2E3Ny44NSw3Ny44NSwwLDAsMC00OS4zOC01MS43MSw0Niw0NiwwLDEsMC01MC45LDBBNzcuODUsNzcuODUsMCwwLDAsNTMuMTcsMjEwSDQ4YTIsMiwwLDAsMS0yLTJWNDhhMiwyLDAsMCwxLDItMkgyMDhhMiwyLDAsMCwxLDIsMloiLz48L3N2Zz4=');}.icon-chat-teardrop{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMzIsMjZhOTguMTEsOTguMTEsMCwwLDAtOTgsOTh2ODRhMTQsMTQsMCwwLDAsMTQsMTRoODRhOTgsOTgsMCwwLDAsMC0xOTZabTAsMTg0SDQ4YTIsMiwwLDAsMS0yLTJWMTI0YTg2LDg2LDAsMSwxLDg2LDg2WiIvPjwvc3ZnPg==');}.icon-caret-left{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjQuMjQsMjAzLjc2YTYsNiwwLDEsMS04LjQ4LDguNDhsLTgwLTgwYTYsNiwwLDAsMSwwLTguNDhsODAtODBhNiw2LDAsMCwxLDguNDgsOC40OEw4OC40OSwxMjhaIi8+PC9zdmc+');}.icon-chat{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTYsNTBINDBBMTQsMTQsMCwwLDAsMjYsNjRWMjI0YTEzLjg4LDEzLjg4LDAsMCwwLDguMDksMTIuNjlBMTQuMTEsMTQuMTEsMCwwLDAsNDAsMjM4YTEzLjg3LDEzLjg3LDAsMCwwLDktMy4zMWwuMDYtLjA1TDgyLjIzLDIwNkgyMTZhMTQsMTQsMCwwLDAsMTQtMTRWNjRBMTQsMTQsMCwwLDAsMjE2LDUwWm0yLDE0MmEyLDIsMCwwLDEtMiwySDgwYTYsNiwwLDAsMC0zLjkyLDEuNDZMNDEuMjYsMjI1LjUzQTIsMiwwLDAsMSwzOCwyMjRWNjRhMiwyLDAsMCwxLDItMkgyMTZhMiwyLDAsMCwxLDIsMloiLz48L3N2Zz4=');}.icon-envelope{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjQsNTBIMzJhNiw2LDAsMCwwLTYsNlYxOTJhMTQsMTQsMCwwLDAsMTQsMTRIMjE2YTE0LDE0LDAsMCwwLDE0LTE0VjU2QTYsNiwwLDAsMCwyMjQsNTBabS05Niw4NS44Nkw0Ny40Miw2MkgyMDguNThaTTEwMS42NywxMjgsMzgsMTg2LjM2VjY5LjY0Wm04Ljg4LDguMTRMMTI0LDE0OC40MmE2LDYsMCwwLDAsOC4xLDBsMTMuNC0xMi4yOEwyMDguNTgsMTk0SDQ3LjQzWk0xNTQuMzMsMTI4LDIxOCw2OS42NFYxODYuMzZaIi8+PC9zdmc+');}.icon-sun-dim{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjIsNDBWMzJhNiw2LDAsMCwxLDEyLDB2OGE2LDYsMCwwLDEtMTIsMFptNjgsODhhNjIsNjIsMCwxLDEtNjItNjJBNjIuMDcsNjIuMDcsMCwwLDEsMTkwLDEyOFptLTEyLDBhNTAsNTAsMCwxLDAtNTAsNTBBNTAuMDYsNTAuMDYsMCwwLDAsMTc4LDEyOFpNNTkuNzYsNjguMjRhNiw2LDAsMSwwLDguNDgtOC40OGwtOC04YTYsNiwwLDAsMC04LjQ4LDguNDhabTAsMTE5LjUyLTgsOGE2LDYsMCwxLDAsOC40OCw4LjQ4bDgtOGE2LDYsMCwxLDAtOC40OC04LjQ4Wm0xMzYtMTM2LTgsOGE2LDYsMCwxLDAsOC40OCw4LjQ4bDgtOGE2LDYsMCwwLDAtOC40OC04LjQ4Wm0uNDgsMTM2YTYsNiwwLDAsMC04LjQ4LDguNDhsOCw4YTYsNiwwLDAsMCw4LjQ4LTguNDhaTTQwLDEyMkgzMmE2LDYsMCwwLDAsMCwxMmg4YTYsNiwwLDAsMCwwLTEyWm04OCw4OGE2LDYsMCwwLDAtNiw2djhhNiw2LDAsMCwwLDEyLDB2LThBNiw2LDAsMCwwLDEyOCwyMTBabTk2LTg4aC04YTYsNiwwLDAsMCwwLDEyaDhhNiw2LDAsMCwwLDAtMTJaIi8+PC9zdmc+');}.icon-moon{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzIuMTMsMTQzLjY0YTYsNiwwLDAsMC02LTEuNDlBOTAuMDcsOTAuMDcsMCwwLDEsMTEzLjg2LDI5Ljg1YTYsNiwwLDAsMC03LjQ5LTcuNDhBMTAyLjg4LDEwMi44OCwwLDAsMCw1NC40OCw1OC42OCwxMDIsMTAyLDAsMCwwLDE5Ny4zMiwyMDEuNTJhMTAyLjg4LDEwMi44OCwwLDAsMCwzNi4zMS01MS44OUE2LDYsMCwwLDAsMjMyLjEzLDE0My42NFptLTQyLDQ4LjI5YTkwLDkwLDAsMCwxLTEyNi0xMjZBOTAuOSw5MC45LDAsMCwxLDk5LjY1LDM3LjY2LDEwMi4wNiwxMDIuMDYsMCwwLDAsMjE4LjM0LDE1Ni4zNSw5MC45LDkwLjksMCwwLDEsMTkwLjEsMTkxLjkzWiIvPjwvc3ZnPg==');}.icon-logo-jakevan{--icon:url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyAgICB3aWR0aD0iMzIiICAgIGhlaWdodD0iMzIiICAgIHZpZXdCb3g9IjAgMCAzMiAzMiIgICAgdmVyc2lvbj0iMS4xIiAgICB4bWw6c3BhY2U9InByZXNlcnZlIiAgICBzdHlsZT0iY2xpcC1ydWxlOmV2ZW5vZGQ7ZmlsbC1ydWxlOmV2ZW5vZGQ7c3Ryb2tlLWxpbmVjYXA6c3F1YXJlO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDoxLjUiICAgIGlkPSJzdmcxNCIgICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiAgICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcyAgICBpZD0iZGVmczE0IiAvPjxwYXRoICAgIGQ9Ik0gMTYuNTgwMDc4LDIuMTMyODEyNSBDIDguODY0ODQ1LDIuMTMyODEyNSAxLjQ0MDYwMjEsOC4xNDY2MjE2IDIuMDMzMjAzMSwxNS44Mzk4NDQgMi41OTQ0NTg4LDIzLjEyNjY2NiA4Ljk3MTIwMTMsMjkuMDI1MTU1IDE1Ljk3MDcwMywyOS43ODcxMDkgMjQuMjIxMjI0LDMwLjY4NTI0IDMwLjQ0MDkxMSwyMy44MzQyNzcgMjkuOTc0NjA5LDE1Ljc5ODgyOCAyOS41Mjc4MTMsOC4wOTk4NjU2IDI0LjI5MTU2LDIuMTMyODEyNSAxNi41ODAwNzgsMi4xMzI4MTI1IFogbSAwLDAuNjA3NDIxOSBoIDAuMDMxMjUgViAyOS4yMzI0MjIgYyAtMC4xOTAzMjMsLTAuMDEyMTggLTAuMzgxNTIxLC0wLjAyNzgzIC0wLjU3NDIxOSwtMC4wNDg4MyBDIDkuMzEzMDQ1NywyOC40NTE2MTcgMy4xNzc4Nzc1LDIyLjc5MzA0NCAyLjYzODY3MTksMTUuNzkyOTY5IDIuMDcyNDU2LDguNDQyMDE1IDkuMjA4MTAwOSwyLjc0MDIzNDQgMTYuNTgwMDc4LDIuNzQwMjM0NCBaIG0gLTAuNDkyMTg3LDEuMTQ0NTMxMiAtMC4wOTE4LDAuMDAzOTEgQyAxMi43MjE1Niw0LjAxODM0MTMgOS41NDY0NzQzLDUuMzY0Njg3NyA3LjI1LDcuNSA0Ljk1MzUyNTcsOS42MzUzMTIzIDMuNTM2MzY0NywxMi41NjQ5OCAzLjc4OTA2MjUsMTUuODQ1NzAzIDQuMjgwODY2OCwyMi4yMzAwMzIgOS44NjA1ODc5LDI3LjM4MzYwOCAxNS45OTAyMzQsMjguMDUyNzM0IGwgMC4wODk4NCwwLjAwOTggMC4wMTk1MywtMC4xODE2NDEgLTAuMDg5ODQsLTAuMDA5OCBDIDkuOTY4NzM4OSwyNy4yMTE2NDMgNC40NTQ5NjM4LDIyLjExNjQ4MSAzLjk3MDcwMzEsMTUuODMwMDc4IDMuNzIyNjQ5MSwxMi42MDk2NDQgNS4xMTE4OTc5LDkuNzM5MDQ3NyA3LjM3NSw3LjYzNDc2NTYgOS42MzgxMDIxLDUuNTMwNDgzNiAxMi43NzQ2MjUsNC4xOTgxOTcgMTYuMDAzOTA2LDQuMDcwMzEyNSBsIDAuMDg5ODQsLTAuMDAzOTEgeiBtIDAsMS41MDE5NTMxIC0wLjA5MTgsMC4wMDM5MSBDIDEzLjEyODA1NCw1LjUwNDE5NzEgMTAuMzQ3NDM0LDYuNjg0MzQ1NCA4LjMzNTkzNzUsOC41NTQ2ODc1IDYuMzI0NDQxMSwxMC40MjUwMyA1LjA4MTMzNDEsMTIuOTkwODI3IDUuMzAyNzM0NCwxNS44NjUyMzQgNS43MzM1MzM2LDIxLjQ1NzY1NiAxMC42MjEyNjUsMjUuOTcwNTQ4IDE1Ljk5MDIzNCwyNi41NTY2NDEgbCAwLjA4OTg0LDAuMDA5OCAwLjAxOTUzLC0wLjE3OTY4NyAtMC4wODk4NCwtMC4wMDk4IEMgMTAuNzI5NDE5LDI1LjgwMDUzMiA1LjkwNzYzMDcsMjEuMzQ2MDU2IDUuNDg0Mzc1LDE1Ljg1MTU2MyA1LjI2NzYxODQsMTMuMDM3NDQyIDYuNDgyODEzMywxMC41MjY4MTIgOC40NjA5Mzc1LDguNjg3NSAxMC40MzkwNjIsNi44NDgxODgzIDEzLjE4MTEyLDUuNjg0MDUyOCAxNi4wMDM5MDYsNS41NzIyNjU2IGwgMC4wODk4NCwtMC4wMDM5MSB6IG0gMCwxLjUwMzkwNjMgLTAuMDkxOCwwLjAwMTk1IEMgMTMuNTM0NTQsNi45OTAwNjYzIDExLjE0ODQsOC4wMDQwMDcyIDkuNDIxODc1LDkuNjA5Mzc1IDcuNjk1MzQ5NywxMS4yMTQ3NDMgNi42MjgyNTU4LDEzLjQxNjY4IDYuODE4MzU5NCwxNS44ODQ3NjYgNy4xODgxNTUxLDIwLjY4NTI2OSAxMS4zODE5NiwyNC41NTk0NDQgMTUuOTkwMjM0LDI1LjA2MjUgbCAwLjA4OTg0LDAuMDA5OCAwLjAxOTUzLC0wLjE4MTY0MSAtMC4wODk4NCwtMC4wMDk4IEMgMTEuNDkwMTE0LDI0LjM4NzQ3NyA3LjM2MjI1MjIsMjAuNTczNjcxIDcsMTUuODcxMDk0IDYuODE0NTQwMSwxMy40NjMyOTcgNy44NTM3MjE5LDExLjMxNjUyNSA5LjU0Njg3NSw5Ljc0MjE4NzUgYyAxLjY5MzE1MywtMS41NzQzMzc0IDQuMDQwNzMsLTIuNTcwMzI2IDYuNDU3MDMxLC0yLjY2NjAxNTYgbCAwLjA4OTg0LC0wLjAwMzkxIHogbSAwLDEuNTAxOTUzMSAtMC4wOTE4LDAuMDAzOTEgYyAtMi4wNTUwNzMsMC4wODEzODQgLTQuMDQ2NzI4LDAuOTI1MjMzMSAtNS40ODgyODEsMi4yNjU2MjQ5IC0xLjQ0MTU1NDcsMS4zNDAzOTMgLTIuMzMyNjM1NCwzLjE4MjM3OSAtMi4xNzM4Mjg2LDUuMjQ0MTQxIDAuMzA2Mzk5NCwzLjk3NzUyIDMuNzU3OTY0Niw3LjE3NDg3NyA3LjU3MDMxMjYsNy42MzQ3NjYgbCAtMC4wMDIsMC4wMTc1OCAwLjA4Nzg5LDAuMDA3OCBoIDAuMDAyIGwgMC4wMDk4LDAuMDAyIDAuMDgwMDgsMC4wMDk4IDAuMDIxNDgsLTAuMTgxNjQxIGggLTAuMDAzOSB2IC0wLjAwMiBsIC0wLjA4OTg0LC0wLjAwOTggaCAtMC4wMDIgQyAxMi4yNDk0NzYsMjIuOTczNTY1IDguODE2ODE1NSwxOS44MDI1NzYgOC41MTU2MTcsMTUuODkyNTcxIDguMzYxNDU0LDEzLjg5MTA5OCA5LjIyNDYyMjUsMTIuMTA2MjMgMTAuNjMyODA1LDEwLjc5Njg2OCAxMi4wNDA5ODUsOS40ODc1MDU0IDEzLjk5NDA4NCw4LjY1NzcwOTcgMTYuMDAzODk4LDguNTc4MTE3NyBsIDAuMDg5ODQsLTAuMDAzOTEgeiBtIDAsMS41MDE5NTMyIC0wLjA5MTgsMC4wMDM5MSBjIC0xLjY0ODU4MiwwLjA2NTI4NyAtMy4yNDU3NjYsMC43NDI5NDI3IC00LjQwMjM0NCwxLjgxODM1OTcgLTEuMTU2NTc4LDEuMDc1NDE3IC0xLjg3MTY1MDMsMi41NTM1NDUgLTEuNzQ0MTQwNiw0LjIwODk4NCAwLjI0NTM5NDYsMy4xODU1OSAzLjAwMzEwMzYsNS43NDQzODEgNi4wNTQ2ODc2LDYuMTIxMDk0IGwgLTAuMDAyLDAuMDE3NTggMC4wODc4OSwwLjAwNzggaCAwLjAwMiBsIDAuMDg3ODksMC4wMDk4IGggMC4wMDIgbCAwLjAyMTQ4LC0wLjE4MTY0IGggLTAuMDAzOSBsIC0wLjA4Nzg5LC0wLjAwOTggaCAtMC4wMDIgLTAuMDAyIGMgLTIuOTk3NjIzLC0wLjMyODE1NiAtNS43MzYzNjgsLTIuODYwNDMzIC01Ljk3NjU2MiwtNS45Nzg1MTYgLTAuMTIyODY1OSwtMS41OTUxNSAwLjU2NDI5NCwtMy4wMTgxMTMgMS42ODc1LC00LjA2MjUgMS4xMjMyMDYsLTEuMDQ0Mzg2IDIuNjgxODMzLC0xLjcwNjAzNyA0LjI4NTE1NiwtMS43Njk1MzEgbCAwLjA4OTg0LC0wLjAwMzkgeiBtIDQuMTIxMDkzLDEuMzIwMzEyNyBoIDEuNDY2Nzk3IGwgMS4zMjgxMjUsMy45NjI4OSB2IC0zLjk0NzI2NSBoIDEuMTAzNTE2IHYgMS43MzYzMjggYyAwLjA0NjM5LC0wLjA4NzQyIDAuMDk5ODksLTAuMTc2MjEyIDAuMTYwMTU2LC0wLjI2MzY3MiAwLjA2MDMxLC0wLjA4NzQ2IDAuMTIwMjk0LC0wLjE2OTY4NyAwLjE3NzczNCwtMC4yNDgwNDcgbCAwLjg1MzUxNiwtMS4yMjQ2MDkgaCAzLjU3ODEyNSB2IDAuODY3MTg3IGggLTEuMzE2NDA2IHYgMC42MjY5NTMgaCAxLjIxODc1IHYgMC44NjcxODggaCAtMS4yMTg3NSB2IDAuNzUzOTA2IGggMS4zMTY0MDYgdiAwLjg4MDg2IGggLTMuNjM4NjcyIGwgLTAuODA0Njg3LC0xLjUyNzM0NCAtMC4zMjYxNzIsMC4xOTE0MDYgdiAxLjMzNTkzOCBoIC0yLjI4OTA2MyBsIC0wLjIwMTE3MiwtMC43NDgwNDcgaCAtMS4zMjAzMTIgbCAtMC4yMDcwMzEsMC43NDgwNDcgaCAtMS4yMDcwMzIgeiBtIC0yLjQxNDA2MiwwLjAxNTYzIGggMS4xMDM1MTUgdiAzLjYwNTQ2OSBjIDEwZS03LDAuNDAwOTA1IC0wLjA2MTE3LDAuNzIyMzU1IC0wLjE4MzU5MywwLjk2Mjg5IC0wLjEyMjQyMiwwLjI0MDUzNSAtMC4yOTA4MTMsMC40MTQwMTEgLTAuNTA3ODEzLDAuNTIxNDg0IC0wLjIxNjk5OSwwLjEwNzUxMyAtMC40NjgyNTUsMC4xNjAxNTcgLTAuNzUzOTA2LDAuMTYwMTU3IC0wLjEyNDI1NiwwIC0wLjIzNDQ2NSwtMC4wMDU3IC0wLjMyODEyNSwtMC4wMTc1OCAtMC4wOTM2NiwtMC4wMTE3NyAtMC4xNzM1NzgsLTAuMDI0NDYgLTAuMjQyMTg4LC0wLjAzOTA2IFYgMTUuNTgzOTkgYyAwLjA1OTM2LDAuMDEwODYgMC4xMjI2NzQsMC4wMjM0MSAwLjE4OTQ1NCwwLjAzNzExIDAuMDY2NzcsMC4wMTM3IDAuMTM4OTM2LDAuMDIxNDggMC4yMTY3OTYsMC4wMjE0OCAwLjEzMTY3NiwwIDAuMjMzMzYxLC0wLjAyNzIzIDAuMzA2NjQxLC0wLjA4MDA4IDAuMDczMjMsLTAuMDUyODYgMC4xMjQ2MTcsLTAuMTMyNjExIDAuMTU0Mjk3LC0wLjIzODI4MSAwLjAyOTY4LC0wLjEwNTY3IDAuMDQ0OTIsLTAuMjM3OTE5IDAuMDQ0OTIsLTAuMzk2NDg1IHogbSA4LjY2Nzk2OSwwLjA1NDY5IC0xLjI0NDE0MSwxLjczNjMyOCAxLjI0NDE0MSwyLjE3NTc4MiB6IG0gLTEwLjM3NSwwLjExMzI4MiAtMC4wOTE4LDAuMDAyIGMgLTEuMjQyMDk1LDAuMDQ5MTkgLTIuNDQ0OCwwLjU2MDY1MiAtMy4zMTY0MDYsMS4zNzEwOTMgLTAuODcxNjA4LDAuODEwNDQyIC0xLjQxMjYyLDEuOTI0NzEyIC0xLjMxNjQwNywzLjE3MzgyOSAwLjE4NDM5LDIuMzkzNjU0IDIuMjUwMTk1LDQuMzEyMDIyIDQuNTQxMDE2LDQuNjA1NDY4IGwgLTAuMDAyLDAuMDE3NTggMC4wODc4OSwwLjAwNzggaCAwLjAwMiBsIDAuMDg3ODksMC4wMDk4IGggMC4wMDIgbCAwLjAxOTUzLC0wLjE3OTY4NyBoIC0wLjAwMiB2IC0wLjAwMiBsIC0wLjA3ODEzLC0wLjAwNzggLTAuMDA5OCwtMC4wMDIgaCAtMC4wMDIgLTAuMDAyIGMgLTIuMjM2OTYxLC0wLjI0NTEyMSAtNC4yODM3LC0yLjEzNjczMSAtNC40NjI4OSwtNC40NjI4OSAtMC4wOTE1NywtMS4xODg4MjYgMC40MjE1MzEsLTIuMjQ3OTMzIDEuMjU5NzY2LC0zLjAyNzM0NCAwLjgzODIzNCwtMC43Nzk0MTEgMi4wMDIzODIsLTEuMjcyOTE2IDMuMTk5MjE4LC0xLjMyMDMxMyBsIDAuMDg5ODQsLTAuMDAzOSB6IG0gNC44NjEzMjgsMC40NzQ2MDkgYyAtMC4wMTY3MSwwLjA5MTExIC0wLjAzOTcyLDAuMjAzOTI4IC0wLjA3MDMxLDAuMzM3ODkxIC0wLjAzMDYsMC4xMzM5MjIgLTAuMDYxMjgsMC4yNjUyNjkgLTAuMDkzNzUsMC4zOTY0ODQgLTAuMDMyNDIsMC4xMzExNzUgLTAuMDYxODUsMC4yNDA2NjUgLTAuMDg1OTQsMC4zMjgxMjUgbCAtMC4xNzU3ODIsMC42NTYyNSBoIDAuODY1MjM1IGwgLTAuMTczODI4LC0wLjY1NjI1IGMgLTAuMDE4NTEsLTAuMDcxMDYgLTAuMDQ2ODEsLTAuMTcyNTI5IC0wLjA4MjAzLC0wLjMwNDY4OCAtMC4wMzUyNiwtMC4xMzIxMTggLTAuMDY5MjEsLTAuMjY4OTM1IC0wLjEwMzUxNSwtMC40MTAxNTYgLTAuMDM0MywtMC4xNDEyMjEgLTAuMDYxNTMsLTAuMjU2NTQ2IC0wLjA4MDA4LC0wLjM0NzY1NiB6IG0gLTQuODYxMzI4LDEuMDI3MzQ0IC0wLjA5MTgsMC4wMDM5IGMgLTAuODM1NjA4LDAuMDMzMDkgLTEuNjQzODM2LDAuMzc0NDU2IC0yLjIzMDQ2OSwwLjkxOTkyMiAtMC41ODY2MzMsMC41NDU0NjYgLTAuOTUxNjM1LDEuMjk5Nzg2IC0wLjg4NjcxOSwyLjE0MjU3OCAwLjEyMzIwNSwxLjU5OTM3OCAxLjQ5ODEyOCwyLjg1OTQ2NyAzLjAyNTM5MSwzLjA3MjI2NSBsIC0wLjAwMzksMC4wMzMyIDAuMDg5ODQsMC4wMDk4IDAuMDgyMDMsMC4wMDc4IDAuMDA3OCwwLjAwMiBoIDAuMDAyIGwgMC4wMTk1MywtMC4xODE2NDEgaCAtMC4wMDIgbCAtMC4wODc4OSwtMC4wMDk4IGggLTAuMDAyIC0wLjAwMiBjIC0xLjQ3NjMzNywtMC4xNjIwNDEgLTIuODI5MDc2LC0xLjQxMjk5NiAtMi45NDcyNjUsLTIuOTQ3MjY2IC0wLjA2MDI3LC0wLjc4MjUwMyAwLjI3NjgxNywtMS40Nzk3MDUgMC44MzAwNzgsLTEuOTk0MTQxIDAuNTUzMjYxLC0wLjUxNDQzNSAxLjMyMjkzMywtMC44NDE3NDggMi4xMTMyODEsLTAuODczMDQ2IGwgMC4wODk4NCwtMC4wMDM5IHogbSAwLDEuNTAxOTUzIC0wLjA5MTgsMC4wMDM5IGMgLTAuNDI5MTE4LDAuMDE2OTkgLTAuODQyODczLDAuMTkyMTY2IC0xLjE0NDUzMSwwLjQ3MjY1NiAtMC4zMDE2NiwwLjI4MDQ5MSAtMC40OTA2NDgsMC42NzA5NTUgLTAuNDU3MDMyLDEuMTA3NDIyIDAuMDYyMTksMC44MDczNiAwLjc0MzgwMSwxLjQzMDI1NyAxLjUwOTc2NiwxLjU1ODU5NCBsIC0wLjAwMzksMC4wMzMyIDAuMDg5ODQsMC4wMDc4IGggMC4wMDIgbCAwLjA4Nzg5LDAuMDA5OCAwLjAyMTQ4LC0wLjE3OTY4NyBoIC0wLjAwMiB2IC0wLjAwMiBsIC0wLjA3ODEzLC0wLjAwNzggLTAuMDA5OCwtMC4wMDIgaCAtMC4wMDIgLTAuMDAyIGMgLTAuNzE1Njc1LC0wLjA3OTAxIC0xLjM3NDQ1NSwtMC42ODkyOSAtMS40MzE2NCwtMS40MzE2NDEgLTAuMDI4OTcsLTAuMzc2MTc5IDAuMTMyMTA0LC0wLjcxMTQ3NyAwLjQwMDM5MSwtMC45NjA5MzcgMC4yNjgyODYsLTAuMjQ5NDYxIDAuNjQzNDg1LC0wLjQwODYyNyAxLjAyNzM0MywtMC40MjM4MjggbCAwLjA4OTg0LC0wLjAwMzkgeiBtIDcuMDQxMDE1LDAuODQ5NjA5IGggMS4yNjE3MTkgbCAxLjE0MjU3OCwzLjQxNDA2MyB2IC0zLjQwMDM5MSBoIDEuMjM2MzI4IGwgMS4yNzUzOTEsMi4zOTI1NzggaCAwLjAxMzY3IGMgLTAuMDA0NywtMC4wNzUzNyAtMC4wMDg5LC0wLjE2NDAzMiAtMC4wMTM2NywtMC4yNjM2NzIgLTAuMDA0OCwtMC4wOTk2NCAtMC4wMDk3LC0wLjIwMDcxMyAtMC4wMTM2NywtMC4zMDI3MzQgLTAuMDA0LC0wLjEwMjAyIC0wLjAwNTksLTAuMTkxMDUxIC0wLjAwNTksLTAuMjY5NTMxIHYgLTEuNTU2NjQxIGggMC44NDM3NSB2IDMuNDQxNDA2IGggLTEuMjQyMTg4IGwgLTEuMjc5Mjk3LC0yLjQyMzgyOCBoIC0wLjAyMTQ4IGMgMC4wMDgsMC4wNzM3NyAwLjAxNTA4LDAuMTYyMDg4IDAuMDIxNDgsMC4yNjU2MjUgMC4wMDY0LDAuMTAzNTc5IDAuMDEyODgsMC4yMDg4OTEgMC4wMTc1OCwwLjMxNjQwNiAwLjAwNDgsMC4xMDc0NzQgMC4wMDc4LDAuMjA0NzA2IDAuMDA3OCwwLjI5MTAxNiB2IDEuNTUwNzgxIEggMjQuNTEzNjcyIEwgMjQuMzM5ODQ0LDE4LjA2MjUgaCAtMS4xMzY3MTkgbCAtMC4xNzc3MzQsMC42NDQ1MzEgaCAtMS4wMzkwNjMgeiBtIC00LjE1NDI5NywwLjAxMzY3IGggMS4wNjI1IGwgMC40NzY1NjMsMS43NDQxNDEgYyAwLjAxNzU0LDAuMDY1ODkgMC4wMzkzNywwLjE1MTEwNyAwLjA2MjUsMC4yNTM5MDYgMC4wMjMxNywwLjEwMjc1OCAwLjA0NDQ4LDAuMjA0NjYxIDAuMDY0NDUsMC4zMDY2NCAwLjAxOTk3LDAuMTAyMDIgMC4wMzIzMSwwLjE4NTYyIDAuMDM3MTEsMC4yNSAwLjAwNjQsLTAuMDY0MzggMC4wMTc2MSwtMC4xNDc2MjUgMC4wMzUxNiwtMC4yNDgwNDYgMC4wMTc1OSwtMC4xMDA0MjEgMC4wMzcwNCwtMC4yMDE1MzUgMC4wNTg1OSwtMC4zMDI3MzUgMC4wMjE2LC0wLjEwMTI0MSAwLjA0MTM4LC0wLjE4NDA2IDAuMDYwNTUsLTAuMjUgbCAwLjQ4NjMyOCwtMS43NTM5MDYgaCAxLjA2MDU0NyBsIC0xLjE0ODQzNywzLjQ0MTQwNiBoIC0xLjExMzI4MiB6IG0gNC43OTEwMTYsMC41NTI3MzQgYyAtMC4wMTQyOSwwLjA3ODQ0IC0wLjAzNDIxLDAuMTc1NjY5IC0wLjA2MDU1LDAuMjkxMDE2IC0wLjAyNjM0LDAuMTE1MzQ3IC0wLjA1NDA2LDAuMjMwNzgyIC0wLjA4MjAzLDAuMzQzNzUgLTAuMDI3OTMsMC4xMTI5NjkgLTAuMDUxNDcsMC4yMDU5MiAtMC4wNzIyNywwLjI4MTI1IGwgLTAuMTUyMzQ0LDAuNTY0NDUzIGggMC43NDYwOTQgbCAtMC4xNTAzOSwtMC41NjQ0NTMgYyAtMC4wMTU5MiwtMC4wNjEyMiAtMC4wMzk4OCwtMC4xNDc5NzEgLTAuMDcwMzEsLTAuMjYxNzE5IC0wLjAzMDI3LC0wLjExMzc4OSAtMC4wNjAyOSwtMC4yMzE5MzUgLTAuMDg5ODQsLTAuMzUzNTE1IC0wLjAyOTU2LC0wLjEyMTYyIC0wLjA1MjM1LC0wLjIyMjM0MiAtMC4wNjgzNiwtMC4zMDA3ODIgeiBtIC0zLjY0NjQ4NCwyLjk5MjE4OCBIIDIwLjU2MjUgYyAwLjE3NDc3NSwwIDAuMzIwNjU4LDAuMDI5NjQgMC40Mzk0NTMsMC4wODk4NCAwLjExODc1NCwwLjA2MDIgMC4yMTAyNTUsMC4xNDg1NTYgMC4yNzE0ODQsMC4yNjU2MjUgMC4wNjEyNywwLjExNzA2OSAwLjA5MTgsMC4yNjE4NjQgMC4wOTE4LDAuNDMzNTkzIDAsMC4xNzk4MDcgLTAuMDMzMDEsMC4zMzEzIC0wLjA5NzY2LDAuNDUzMTI1IC0wLjA2NDYxLDAuMTIxODI2IC0wLjE2MDQ3NywwLjIxMzc2MSAtMC4yODcxMDksMC4yNzUzOTEgLTAuMTI2NTksMC4wNjE2NyAtMC4yODM5NjUsMC4wOTE4IC0wLjQ3MDcwMywwLjA5MTggSCAyMC4xMTkxNCBaIG0gMS40MzU1NDYsMCBoIDAuODk2NDg1IHYgMC4xMDE1NjIgaCAtMC43ODEyNSB2IDAuNjExMzI4IEggMjIuNDA2MjUgViAxOS42MjUgaCAtMC43MzYzMjggdiAwLjY5MzM1OSBoIDAuNzgxMjUgdiAwLjEwMTU2MyBoIC0wLjg5NjQ4NSB6IG0gMS4wODM5ODUsMCBoIDAuMzk4NDM3IGMgMC4xMzAwMDgsMCAwLjIzNzIyOSwwLjAxMzA5IDAuMzI0MjE5LDAuMDQxMDIgMC4wODcwMywwLjAyNzg4IDAuMTUzMTY2LDAuMDc0NzggMC4xOTcyNjYsMC4xMzg2NzIgMC4wNDQwMiwwLjA2Mzg0IDAuMDY2NDEsMC4xNDkxOTEgMC4wNjY0MSwwLjI1NzgxMyAwLDAuMDgxNDggLTAuMDE0NjIsMC4xNTI1ODcgLTAuMDQ0OTIsMC4yMTA5MzcgLTAuMDMwMywwLjA1ODM1IC0wLjA3MjAyLDAuMTA1MTc4IC0wLjEyNSwwLjE0MjU3OCAtMC4wNTMxLDAuMDM3NDggLTAuMTE0MTU0LDAuMDY2MTMgLTAuMTgzNTk0LDAuMDg1OTQgbCAwLjQ0OTIxOSwwLjczMjQyMiBIIDIzLjU4Mzk5IGwgLTAuNDIzODI4LC0wLjY5OTIxOSBoIC0wLjQwNjI1IHYgMC42OTkyMTkgaCAtMC4xMTUyMzQgeiBtIDEuMDA3ODEyLDAgaCAwLjExOTE0MSBsIDAuMzA4NTk0LDEuMTM0NzY1IGMgMC4wMDgxLDAuMDMwMDkgMC4wMTQzOCwwLjA1ODc5IDAuMDIxNDgsMC4wODU5NCAwLjAwNywwLjAyNzE1IDAuMDEzNTMsMC4wNTI4MSAwLjAxOTUzLDAuMDc4MTMgMC4wMDYsMC4wMjUzNCAwLjAxMTk4LDAuMDUwMzUgMC4wMTc1OCwwLjA3NDIyIDAuMDA1NiwwLjAyMzgzIDAuMDExMTIsMC4wNDgwNCAwLjAxNTYzLDAuMDcyMjcgMC4wMDUyLC0wLjAyNDI0IDAuMDEwNDMsLTAuMDQ5MjUgMC4wMTU2MywtMC4wNzQyMiAwLjAwNTIsLTAuMDI0OTMgMC4wMTEyOCwtMC4wNDg1NSAwLjAxNzU4LC0wLjA3NDIyIDAuMDA2NCwtMC4wMjU3MSAwLjAxMjMzLC0wLjA1MjU2IDAuMDE5NTMsLTAuMDgwMDggMC4wMDcsLTAuMDI3NTIgMC4wMTYzOSwtMC4wNTc3OSAwLjAyNTM5LC0wLjA4Nzg5IGwgMC4zMjAzMTMsLTEuMTI4OTA2IGggMC4xMTUyMzQgbCAwLjMzMzk4NSwxLjEzNjcxOSBjIDAuMDA5LDAuMDMxNTMgMC4wMTc5OSwwLjA1OTk3IDAuMDI1MzksMC4wODc4OSAwLjAwNzUsMC4wMjc4OSAwLjAxMzUzLDAuMDU0NzQgMC4wMTk1MywwLjA4MDA4IDAuMDA1OSwwLjAyNTMgMC4wMTIyOCwwLjA1MDM1IDAuMDE3NTgsMC4wNzQyMiAwLjAwNTIsMC4wMjM4MyAwLjAxMDQzLDAuMDQ4MDggMC4wMTU2MywwLjA3MjI3IDAuMDA2LC0wLjAzMzAxIDAuMDExMTgsLTAuMDY1NzIgMC4wMTc1OCwtMC4wOTc2NiAwLjAwNjMsLTAuMDMxOSAwLjAxNDQ0LC0wLjA2NDM5IDAuMDIzNDQsLTAuMDk5NjEgMC4wMDksLTAuMDM1MjYgMC4wMiwtMC4wNzUzNyAwLjAzMTI1LC0wLjExNzE4NyBsIDAuMzE0NDUzLC0xLjEzNjcxOSBoIDAuMTE5MTQxIGwgLTAuNDQ3MjY2LDEuNjA5Mzc1IGggLTAuMTExMzI4IGwgLTAuMzUxNTYyLC0xLjIxNDg0NCBjIC0wLjAwODMsLTAuMDI1NjcgLTAuMDE1MDgsLTAuMDUxMDkgLTAuMDIxNDgsLTAuMDc0MjIgLTAuMDA2MywtMC4wMjMxMyAtMC4wMTE4OCwtMC4wNDM1NCAtMC4wMTc1OCwtMC4wNjQ0NSAtMC4wMDU2LC0wLjAyMDkyIC0wLjAxMDczLC0wLjA0MTg1IC0wLjAxNTYzLC0wLjA2MDU1IC0wLjAwNDgsLTAuMDE4NjkgLTAuMDA4NywtMC4wMzU0IC0wLjAxMTcyLC0wLjA1MDc4IC0wLjAwMjksMC4wMTUzOCAtMC4wMDY0LDAuMDMxNjEgLTAuMDA5OCwwLjA0ODgzIC0wLjAwMzQsMC4wMTcyNiAtMC4wMDcyLDAuMDM0MzggLTAuMDExNzIsMC4wNTI3MyAtMC4wMDQ1LDAuMDE4MzMgLTAuMDA4NCwwLjAzODc0IC0wLjAxMzY3LDAuMDU4NTkgLTAuMDA1MiwwLjAxOTgxIC0wLjAxMTU4LDAuMDM5MjcgLTAuMDE3NTgsMC4wNjA1NSBsIC0wLjM0OTYwOSwxLjI0NDE0MSBoIC0wLjExMzI4MSB6IG0gMi4wMzUxNTcsMCBoIDAuODk2NDg0IHYgMC4xMDE1NjIgaCAtMC43NzkyOTcgdiAwLjYxMTMyOCBoIDAuNzM2MzI4IFYgMTkuNjI1IGggLTAuNzM2MzI4IHYgMC42OTMzNTkgaCAwLjc3OTI5NyB2IDAuMTAxNTYzIGggLTAuODk2NDg0IHogbSAxLjA4Mzk4NCwwIGggMC4zOTg0MzcgYyAwLjEyOTkyNiwwIDAuMjM5MTQyLDAuMDEzMDkgMC4zMjYxNzIsMC4wNDEwMiAwLjA4Njk5LDAuMDI3ODggMC4xNTEyMTMsMC4wNzQ3OCAwLjE5NTMxMywwLjEzODY3MiAwLjA0NDAyLDAuMDYzODQgMC4wNjY0MSwwLjE0OTE5MSAwLjA2NjQxLDAuMjU3ODEzIDAsMC4wODE0OCAtMC4wMTQ2MiwwLjE1MjU4NyAtMC4wNDQ5MiwwLjIxMDkzNyAtMC4wMzAyNiwwLjA1ODM1IC0wLjA3MjAyLDAuMTA1MTc4IC0wLjEyNSwwLjE0MjU3OCAtMC4wNTMwNiwwLjAzNzQ4IC0wLjExNDE1MywwLjA2NjEzIC0wLjE4MzU5NCwwLjA4NTk0IGwgMC40NDkyMTksMC43MzI0MjIgaCAtMC4xMzY3MTkgbCAtMC40MjE4NzUsLTAuNjk5MjE5IGggLTAuNDA4MjAzIHYgMC42OTkyMTkgaCAtMC4xMTUyMzQgeiBtIDEuMTgxNjQxLDAgaCAwLjg5NjQ4NCB2IDAuMTAxNTYyIEggMjguMDYyNSB2IDAuNjg3NSBoIDAuNzM4MjgxIHYgMC4wOTk2MSBIIDI4LjA2MjUgdiAwLjcyMDcwMyBoIC0wLjExNTIzNCB6IG0gLTcuNzEyODkxLDAuMDk5NjEgdiAxLjQxMDE1NiBoIDAuMjcxNDg0IGMgMC4yNDcyNjEsMCAwLjQzMjE4MywtMC4wNjA0IDAuNTU0Njg4LC0wLjE3OTY4NyAwLjEyMjU0OSwtMC4xMTkyNDIgMC4xODM1OTQsLTAuMjk4NTQyIDAuMTgzNTk0LC0wLjUzNzEwOSAwLC0wLjE1MzM1OCAtMC4wMjUzNiwtMC4yODAwNTQgLTAuMDc2MTcsLTAuMzgyODEzIC0wLjA1MDc3LC0wLjEwMjc1OCAtMC4xMjk3OTMsLTAuMTgwNjcyIC0wLjIzNDM3NSwtMC4yMzI0MjIgLTAuMTA0NTgyLC0wLjA1MTcxIC0wLjIzNTg4MiwtMC4wNzgxMyAtMC4zOTY0ODUsLTAuMDc4MTMgeiBtIDIuNTE5NTMxLDAgdiAwLjcwODk4NSBoIDAuMzI2MTcyIGMgMC4xMzM3NiwwIDAuMjM3NDcsLTAuMDMwNjQgMC4zMTI1LC0wLjA5Mzc1IDAuMDc1MTYsLTAuMDYzMTUgMC4xMTMyODEsLTAuMTUzMzUgMC4xMTMyODEsLTAuMjcxNDg1IDAsLTAuMTI5OTQ0IC0wLjAzOTk4LC0wLjIyMDMyMSAtMC4xMTkxNCwtMC4yNjk1MzEgLTAuMDc5MjQsLTAuMDQ5MTcgLTAuMTk5MjI0LC0wLjA3NDIyIC0wLjM2MTMyOCwtMC4wNzQyMiB6IG0gNC4xMjY5NTMsMCB2IDAuNzA4OTg1IGggMC4zMjYxNzIgYyAwLjEzMzc1OSwwIDAuMjM5NDIzLC0wLjAzMDY0IDAuMzE0NDUzLC0wLjA5Mzc1IDAuMDc1MTIsLTAuMDYzMTUgMC4xMTEzMjgsLTAuMTUzMzUgMC4xMTEzMjgsLTAuMjcxNDg1IDAsLTAuMTI5OTQ0IC0wLjAzODA4LC0wLjIyMDMyMSAtMC4xMTcxODcsLTAuMjY5NTMxIC0wLjA3OTI0LC0wLjA0OTE3IC0wLjIwMTE3OCwtMC4wNzQyMiAtMC4zNjMyODEsLTAuMDc0MjIgeiIgICAgc3R5bGU9ImJhc2VsaW5lLXNoaWZ0OmJhc2VsaW5lO2NsaXAtcnVsZTpub256ZXJvO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7dmVjdG9yLWVmZmVjdDpub25lO2ZpbGw6IzIyMjIyMjtmaWxsLXJ1bGU6bm9uemVybztzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlO3N0b3AtY29sb3I6IzAwMDAwMDtzdG9wLW9wYWNpdHk6MSIgICAgaWQ9InBhdGgxNyIgLz48L3N2Zz4=');}.icon-x{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDQuMjQsMTk1Ljc2YTYsNiwwLDEsMS04LjQ4LDguNDhMMTI4LDEzNi40OSw2MC4yNCwyMDQuMjRhNiw2LDAsMCwxLTguNDgtOC40OEwxMTkuNTEsMTI4LDUxLjc2LDYwLjI0YTYsNiwwLDAsMSw4LjQ4LTguNDhMMTI4LDExOS41MWw2Ny43Ni02Ny43NWE2LDYsMCwwLDEsOC40OCw4LjQ4TDEzNi40OSwxMjhaIi8+PC9zdmc+');}.icon-loading{--icon:url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyAgICB3aWR0aD0iMzIiICAgIGhlaWdodD0iMzIiICAgIHZpZXdCb3g9IjAgMCAzMiAzMiIgICAgdmVyc2lvbj0iMS4xIiAgICB4bWw6c3BhY2U9InByZXNlcnZlIiAgICBzdHlsZT0iY2xpcC1ydWxlOmV2ZW5vZGQ7ZmlsbC1ydWxlOmV2ZW5vZGQ7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7c3Ryb2tlLWxpbmVqb2luOnJvdW5kO3N0cm9rZS1taXRlcmxpbWl0OjEuNSIgICAgaWQ9InN2ZzEwIiAgICB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxkZWZzICAgIGlkPSJkZWZzMTAiIC8+PHBhdGggICAgaWQ9InBhdGgxMSIgICAgc3R5bGU9ImJhc2VsaW5lLXNoaWZ0OmJhc2VsaW5lO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7dmVjdG9yLWVmZmVjdDpub25lO2ZpbGw6IzIyMjIyMjtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlO3N0b3AtY29sb3I6IzAwMDAwMDtzdG9wLW9wYWNpdHk6MSIgICAgZD0ibSAxNi42MjEwOTQsMS4xNDI1NzgxIGMgLTguMjY2MzIzMiwwIC0xNi4yMjA4NjczOCw2LjQ0MjgwOTUgLTE1LjU4NTkzNzgsMTQuNjg1NTQ2OSAwLjYwMTM0NTUsNy44MDczMDggNy40MzQxMjY0LDE0LjEyNjk4IDE0LjkzMzU5MzgsMTQuOTQzMzU5IDguODM5ODQ1LDAuOTYyMjgzIDE1LjUwNTQ2OSwtNi4zNzY5MTkgMTUuMDA1ODU5LC0xNC45ODYzMjggQyAzMC40OTU5LDcuNTM2MjY4NCAyNC44ODMzOTcsMS4xNDI1NzgxIDE2LjYyMTA5NCwxLjE0MjU3ODEgWiBtIDAsMC42NTAzOTA3IEMgMjYuNDg4Nzg2LDEuODAzODY0NSAyOS43MTQ1MTgsOS41OTM1ODMzIDMwLjMwMjczNCwxNS44MDQ2ODggMzEuMTQxOTgyLDI0LjY2NjM2NSAyMi4xNjA0NTksMzEuMTY4MDc3IDE2LjAzOTA2MiwzMC4xMjUgOC44OTUxMzI3LDI4LjkwNzY4MSAyLjI2MTMxNDIsMjMuMjc5Mzc2IDEuNjgzNTkzOCwxNS43NzkyOTcgMS4wNzY5MzM4LDcuOTAzMjc1NCA4LjcyMjU0NTEsMS43ODQyNjk5IDE2LjYyMTA5NCwxLjc5Mjk2ODggWiBtIC0wLjA2NDQ1LDEuMjE4NzUgYyAtMy42MTAwODMsMCAtNy4xNTQ3OTk1LDEuNDAxMDY4NyAtOS43MzA0NjkxLDMuNzAzMTI1IEMgNC4yNTA1MDIzLDkuMDE2OTAwMiAyLjY0MjAzNzIsMTIuMjI2Mjk1IDIuOTE5OTIxOSwxNS44MzM5ODQgMy40NDY5MzUsMjIuNjc1NzEyIDkuNDI4OTY0OSwyOC4xOTg5ODUgMTUuOTk4MDQ3LDI4LjkxNDA2MiAyMy43MTQyNTYsMjkuNzU0MDIzIDI5LjUzMTYwMywyMy4zMzE3IDI5LjA5NTcwMywxNS44MjAzMTIgMjguNjc3OTQ4LDguNjIxMzk1MyAyMy43NzY2ODYsMy4wMTE3MTg4IDE2LjU1NjY0MSwzLjAxMTcxODggWiBtIDAsMC4xOTUzMTI0IGMgNy4xMTkxMzQsMCAxMS45MzI3MSw1LjUwODEzNzMgMTIuMzQ1NzAzLDEyLjYyNDk5OTggQyAyOS4zMzIwNjIsMjMuMjM2ODk2IDIzLjYxODk1OCwyOS41NDU5OTggMTYuMDE5NTMxLDI4LjcxODc1IDkuNTQ1NDMyMSwyOC4wMTQwMTIgMy42MzQxNjM3LDIyLjU1NTE0MyAzLjExNTIzNDQsMTUuODE4MzU5IDIuODQyNDU2MywxMi4yNzY5NjcgNC40MTg0MTA5LDkuMTI4MzE2OSA2Ljk1NzAzMTIsNi44NTkzNzUgOS40OTU2NTE2LDQuNTkwNDMzMSAxMi45OTcwOTMsMy4yMDcwMzEyIDE2LjU1NjY0MSwzLjIwNzAzMTIgWiBtIC0wLjA3MDMxLDEuNDE2MDE1NyBjIC0zLjE2MTk3MywwIC02LjI2MzUwOSwxLjIyNTgxMzkgLTguNTE5NTMxMSwzLjI0MjE4NzUgQyA1LjcxMDc2OTEsOS44ODE2MDggNC4zMDE0NTQyLDEyLjY5NDU4OSA0LjU0NDkyMTksMTUuODU1NDY5IDUuMDA2NTYyNCwyMS44NDg1NTQgMTAuMjQ0MTc4LDI2LjY4NjE1OSAxNS45OTgwNDcsMjcuMzEyNSAyMi43NTcwMTMsMjguMDQ4MjYxIDI3Ljg1NDQ1MSwyMi40MjA5MzYgMjcuNDcyNjU2LDE1Ljg0MTc5NyAyNy4xMDY4MjQsOS41Mzc2MDI1IDIyLjgxMDE2LDQuNjIzMDQ2OSAxNi40ODYzMjgsNC42MjMwNDY5IFogbSAwLDAuMTk1MzEyNSBjIDYuMjIyOTIsMCAxMC40Mjk5NDYsNC44MTMwMTM4IDEwLjc5MTAxNiwxMS4wMzUxNTY2IDAuMzc1NjEzLDYuNDcyNjE1IC00LjYxNzU4NCwxMS45ODY3MiAtMTEuMjU5NzY2LDExLjI2MzY3MiBDIDEwLjM1ODY4NSwyNi41MDExODYgNS4xOTE4MzgxLDIxLjcyNzk4NSA0LjczODI4MTIsMTUuODM5ODQ0IDQuNDk5OTIwMSwxMi43NDUyNjIgNS44NzY3MzE1LDkuOTk0OTc3OCA4LjA5NTcwMzEsOC4wMTE3MTg4IDEwLjMxNDY3NSw2LjAyODQ1OTUgMTMuMzc0ODksNC44MTgzNTk0IDE2LjQ4NjMyOCw0LjgxODM1OTQgWiBtIC0wLjA2ODM2LDEuNDE2MDE1NiBjIC0yLjcxMzg3NywwIC01LjM3NjExOCwxLjA1MjUxNjQgLTcuMzEyNTAwMiwyLjc4MzIwMzEgLTEuOTM2MzgyOCwxLjczMDY4NjkgLTMuMTQ2NTUxNyw0LjE0NTMxMTkgLTIuOTM3NSw2Ljg1OTM3NDkgMC4zOTYyNjk5LDUuMTQ0NDMgNC44ODk0NDQyLDkuMjk0NDI5IDkuODI4MTI1Miw5LjgzMjAzMSA1LjgwMTc0OSwwLjYzMTU2MiAxMC4xNzkyNTcsLTQuMTk4ODI4IDkuODUxNTYyLC05Ljg0NTcwMyBDIDI1LjUzMzc1LDEwLjQ1MzgyMiAyMS44NDU2MTYsNi4yMzQzNzUgMTYuNDE3OTc0LDYuMjM0Mzc1IFogbSAwLDAuMTk1MzEyNSBjIDUuMzI2NzMsMCA4LjkyNTIyNiw0LjExNzkwNTUgOS4yMzQzNzUsOS40NDUzMTI1IDAuMzIxNTEzLDUuNTQwMzUxIC0zLjk0OTgwMSwxMC4yNTk0NzQgLTkuNjM0NzY2LDkuNjQwNjI1IEMgMTEuMTczODc1LDI0Ljk4ODM2MiA2Ljc0OTUxNDMsMjAuOTAwODE0IDYuMzYxMzI4MSwxNS44NjEzMjggNi4xNTczODMxLDEzLjIxMzU2MyA3LjMzNTA0MzEsMTAuODU5NjgyIDkuMjM0Mzc1LDkuMTYyMTA5NCAxMS4xMzM3MDcsNy40NjQ1MzcyIDEzLjc1NDYyOCw2LjQyOTY4NzUgMTYuNDE3OTY5LDYuNDI5Njg3NSBaIG0gLTAuMDY4MzYsMS40MTYwMTU2IGMgLTIuMjY1Nzc1LDAgLTQuNDg4NzI5LDAuODc5MjE5NiAtNi4xMDU0NjgsMi4zMjQyMTg5IC0xLjYxNjc0MDgsMS40NDQ5OTkgLTIuNjI3NzYwNywzLjQ2MTI2OSAtMi40NTMxMjU0LDUuNzI4NTE2IDAuMzMwODk4Niw0LjI5NTc2OCA0LjA4MTU5NjQsNy43NjAxMiA4LjIwNTA3ODQsOC4yMDg5ODQgNC44NDQ1MjUsMC41MjczNiA4LjUwMDE1NiwtMy41MDYwOTcgOC4yMjY1NjIsLTguMjIwNzAzIEMgMjMuOTYwNjcyLDExLjM3MTk5NiAyMC44ODEwNiw3Ljg0NTcwMzEgMTYuMzQ5NjE0LDcuODQ1NzAzMSBaIG0gMCwwLjE5NTMxMjUgYyA0LjQzMDUzNCwwIDcuNDIyNDYxLDMuNDIyNzk5NCA3LjY3OTY4OCw3Ljg1NTQ2ODQgMC4yNjc0MTIsNC42MDgwODIgLTMuMjgzOTc4LDguNTMyMjI2IC04LjAxMTcxOSw4LjAxNzU3OCBDIDExLjk4OTA3NSwyMy40NzU1MzggOC4zMDcxODk5LDIwLjA3NTU5MyA3Ljk4NDM3NSwxNS44ODQ3NjYgNy44MTQ4NDYzLDEzLjY4MzgxOSA4Ljc5NTMxMDUsMTEuNzI2MzM4IDEwLjM3NSwxMC4zMTQ0NTMgMTEuOTU0Njg5LDguOTAyNTY4OSAxNC4xMzQzNyw4LjA0MTAxNTYgMTYuMzQ5NjA5LDguMDQxMDE1NiBaIG0gLTAuMDY4MzYsMS40MTYwMTU2IGMgLTEuODE3NjcyLDAgLTMuNjAxMzQyLDAuNzAzOTY4OCAtNC44OTg0MzgsMS44NjMyODA4IC0xLjI5NzA5NSwxLjE1OTMxIC0yLjEwODk2ODMsMi43NzkxODUgLTEuOTY4NzQ5NSw0LjU5OTYxIDAuMjY1NTI2OSwzLjQ0NzExMSAzLjI3Mzc1MDUsNi4yMjU4MTMgNi41ODIwMzE1LDYuNTg1OTM3IDMuODg3Mjk1LDAuNDIzMTYgNi44MjMwMDgsLTIuODE1MzE4IDYuNjAzNTE1LC02LjU5NzY1NiBDIDIyLjM4OTU0MSwxMi4yODgyMjIgMTkuOTE2NDk1LDkuNDU3MDMxMSAxNi4yODEyNSw5LjQ1NzAzMTIgWiBtIDAsMC4xOTUzMTI2IGMgMy41MzQzMzMsMCA1LjkxNzc0MiwyLjcyNzY5NjIgNi4xMjMwNDcsNi4yNjU2MjUyIDAuMjEzMzExLDMuNjc1ODE0IC0yLjYxNjIwOCw2LjgwMzAyNSAtNi4zODY3MTksNi4zOTI1NzggLTMuMjEzMjk4LC0wLjM0OTc4NSAtNi4xNTA3NTk3LC0zLjA2MjEzIC02LjQwODIwMywtNi40MDQyOTcgLTAuMTM1MTEyMiwtMS43NTQxMjcgMC42NDQyNTIsLTMuMzEzMjU3IDEuOTA0Mjk3LC00LjQzOTQ1MyAxLjI2MDA0NSwtMS4xMjYxOTYgMy4wMDA0NDEsLTEuODE0NDUzMyA0Ljc2NzU3OCwtMS44MTQ0NTMyIHogbSAtMC4wNzAzMSwxLjQxNjAxNTIgYyAtMS4zNjk1NzIsMCAtMi43MTIsMC41MzA2NzUgLTMuNjg5NDU0LDEuNDA0Mjk3IC0wLjk3NzQ1MywwLjg3MzYyMiAtMS41OTAxNzcsMi4wOTUxNDUgLTEuNDg0Mzc1LDMuNDY4NzUgMC4yMDAxNTYsMi41OTg0NTIgMi40NjU5LDQuNjg5NTUxIDQuOTU4OTg1LDQuOTYwOTM4IDIuOTMwMDcsMC4zMTg5NTggNS4xNDM5MDgsLTIuMTIyNTg3IDQuOTc4NTE1LC00Ljk3MjY1NiAtMC4xNTgxNDUsLTIuNzI1MjQ0IC0yLjAyNDYyMiwtNC44NjEzMjkgLTQuNzYzNjcxLC00Ljg2MTMyOSB6IG0gMCwwLjE5NTMxMyBjIDIuNjM4MTM1LDAgNC40MTQ5NzUsMi4wMzQ1NDQgNC41NjgzNTksNC42Nzc3MzQgMC4xNTkyMTEsMi43NDM1NDYgLTEuOTUwMzg2LDUuMDczODI0IC00Ljc2MzY3Miw0Ljc2NzU3OCAtMi4zOTgxMDIsLTAuMjYxMDQ3IC00LjU5MTEzMSwtMi4yODc3NDEgLTQuNzgzMjAzLC00Ljc4MTI1IC0wLjEwMDY5NiwtMS4zMDczMDggMC40Nzk1MTksLTIuNDcwMDM5IDEuNDE5OTIyLC0zLjMxMDU0NiAwLjk0MDQwMywtMC44NDA1MDggMi4yMzk1NTcsLTEuMzUzNTE2IDMuNTU4NTk0LC0xLjM1MzUxNiB6IG0gLTAuMDY4MzYsMS40MTYwMTYgYyAtMC45MjE0NzIsMCAtMS44MjI2NTcsMC4zNTU0MjUgLTIuNDgwNDY5LDAuOTQzMzU5IC0wLjY1NzgxMSwwLjU4NzkzNCAtMS4wNzMzMzksMS40MTUwMSAtMS4wMDE5NTMsMi4zNDE3OTcgMC4xMzQ3ODUsMS43NDk3OTIgMS42NTYwOTUsMy4xNTMyOTEgMy4zMzM5ODUsMy4zMzU5MzcgMS45NzI4NDYsMC4yMTQ3NTkgMy40NjY3NiwtMS40MzE4MDkgMy4zNTU0NjgsLTMuMzQ5NjA5IC0wLjEwNjIyNCwtMS44MzA1MDMgLTEuMzY0MTc3LC0zLjI3MTQ4NyAtMy4yMDcwMzEsLTMuMjcxNDg0IHogbSAwLDAuMTk1MzEyIGMgMS43NDE5NDIsMCAyLjkxMjIwOSwxLjMzOTQ0IDMuMDEzNjcyLDMuMDg3ODkxIDAuMTA1MTEsMS44MTEyNzYgLTEuMjg0NTYyLDMuMzQ2NTc3IC0zLjE0MDYyNSwzLjE0NDUzMSAtMS41ODI5MDcsLTAuMTcyMzA3IC0zLjAzMzQ1NSwtMS41MTMzNTUgLTMuMTYwMTU2LC0zLjE1ODIwMyAtMC4wNjYyOCwtMC44NjA0OSAwLjMxNDc4NSwtMS42MjQ4NjggMC45MzU1NDcsLTIuMTc5Njg4IDAuNjIwNzQ5LC0wLjU1NDgxOSAxLjQ4MDYyLC0wLjg5NDUzMSAyLjM1MTU1NiwtMC44OTQ1MzEgeiBtIC0wLjA2ODM2LDEuNDE2MDE2IGMgLTAuNDczMzY5LDAgLTAuOTM1MjcxLDAuMTgyMTI5IC0xLjI3MzQzOCwwLjQ4NDM3NSAtMC4zMzgxNjcsMC4zMDIyNDYgLTAuNTU0NTQ2LDAuNzMwOTY5IC0wLjUxNzU3OCwxLjIxMDkzNyAwLjA2OTQxLDAuOTAxMTMzIDAuODQ4MjQ5LDEuNjE4OTgxIDEuNzEwOTM4LDEuNzEyODkxIDEuMDE1NjE2LDAuMTEwNTU3IDEuNzg5NjE0LC0wLjc0MTAzMSAxLjczMjQyMSwtMS43MjY1NjMgLTAuMDU0MywtMC45MzU3NjYgLTAuNzA1NjkxLC0xLjY4MTY0IC0xLjY1MjM0MywtMS42ODE2NCB6IG0gMCwwLjE5NTMxMiBjIDAuODQ1NzQsMCAxLjQwNzQ5LDAuNjQ0MzMzIDEuNDU3MDMxLDEuNDk4MDQ3IDAuMDUxMDEsMC44NzkwMDggLTAuNjE2NzkzLDEuNjE5MzI5IC0xLjUxNTYyNSwxLjUyMTQ4NCAtMC43Njc3MDYsLTAuMDgzNTcgLTEuNDc1NzgsLTAuNzM4OTY3IC0xLjUzNzEwOSwtMS41MzUxNTYgLTAuMDMxODYsLTAuNDEzNjcxIDAuMTUwMDU1LC0wLjc3OTY5NyAwLjQ1MTE3MiwtMS4wNDg4MjggMC4zMDExMTYsLTAuMjY5MTMxIDAuNzIxNjk4LC0wLjQzNTU0NyAxLjE0NDUzMSwtMC40MzU1NDcgeiIgLz48L3N2Zz4=');}.icon-house-simple{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTcuOSwxMTAuMWwtODAtODBhMTQsMTQsMCwwLDAtMTkuOCwwbC04MCw4MEExMy45MiwxMy45MiwwLDAsMCwzNCwxMjB2OTZhNiw2LDAsMCwwLDYsNkgyMTZhNiw2LDAsMCwwLDYtNlYxMjBBMTMuOTIsMTMuOTIsMCwwLDAsMjE3LjksMTEwLjFaTTIxMCwyMTBINDZWMTIwYTIsMiwwLDAsMSwuNTgtMS40Mmw4MC04MGEyLDIsMCwwLDEsMi44NCwwbDgwLDgwQTIsMiwwLDAsMSwyMTAsMTIwWiIvPjwvc3ZnPg==');}.icon-house{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTcuOSwxMTAuMWwtODAtODBhMTQsMTQsMCwwLDAtMTkuOCwwbC04MCw4MEExMy45MiwxMy45MiwwLDAsMCwzNCwxMjB2OTZhNiw2LDAsMCwwLDYsNmg2NGE2LDYsMCwwLDAsNi02VjE1OGgzNnY1OGE2LDYsMCwwLDAsNiw2aDY0YTYsNiwwLDAsMCw2LTZWMTIwQTEzLjkyLDEzLjkyLDAsMCwwLDIxNy45LDExMC4xWk0yMTAsMjEwSDE1OFYxNTJhNiw2LDAsMCwwLTYtNkgxMDRhNiw2LDAsMCwwLTYsNnY1OEg0NlYxMjBhMiwyLDAsMCwxLC41OC0xLjQybDgwLTgwYTIsMiwwLDAsMSwyLjg0LDBsODAsODBBMiwyLDAsMCwxLDIxMCwxMjBaIi8+PC9zdmc+');}.icon-x-circle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNjQuMjQsMTAwLjI0LDEzNi40OCwxMjhsMjcuNzYsMjcuNzZhNiw2LDAsMSwxLTguNDgsOC40OEwxMjgsMTM2LjQ4bC0yNy43NiwyNy43NmE2LDYsMCwwLDEtOC40OC04LjQ4TDExOS41MiwxMjgsOTEuNzYsMTAwLjI0YTYsNiwwLDAsMSw4LjQ4LTguNDhMMTI4LDExOS41MmwyNy43Ni0yNy43NmE2LDYsMCwwLDEsOC40OCw4LjQ4Wk0yMzAsMTI4QTEwMiwxMDIsMCwxLDEsMTI4LDI2LDEwMi4xMiwxMDIuMTIsMCwwLDEsMjMwLDEyOFptLTEyLDBhOTAsOTAsMCwxLDAtOTAsOTBBOTAuMSw5MC4xLDAsMCwwLDIxOCwxMjhaIi8+PC9zdmc+');}.icon-plus-square{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMzRINDhBMTQsMTQsMCwwLDAsMzQsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFY0OEExNCwxNCwwLDAsMCwyMDgsMzRabTIsMTc0YTIsMiwwLDAsMS0yLDJINDhhMiwyLDAsMCwxLTItMlY0OGEyLDIsMCwwLDEsMi0ySDIwOGEyLDIsMCwwLDEsMiwyWm0tMzYtODBhNiw2LDAsMCwxLTYsNkgxMzR2MzRhNiw2LDAsMCwxLTEyLDBWMTM0SDg4YTYsNiwwLDAsMSwwLTEyaDM0Vjg4YTYsNiwwLDAsMSwxMiwwdjM0aDM0QTYsNiwwLDAsMSwxNzQsMTI4WiIvPjwvc3ZnPg==');}.icon-infinity{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDYsMTI4YTU0LDU0LDAsMCwxLTkyLjE4LDM4LjE4LDMuMDcsMy4wNywwLDAsMS0uMjUtLjI2bC02MC02Ny43NGE0Miw0MiwwLDEsMCwwLDU5LjY0bDguNTctOS42N2E2LDYsMCwxLDEsOSw4bC04LjY5LDkuODFhMy4wNywzLjA3LDAsMCwxLS4yNS4yNiw1NCw1NCwwLDEsMSwwLTc2LjM2LDMuMDcsMy4wNywwLDAsMSwuMjUuMjZsNjAsNjcuNzRhNDIsNDIsMCwxLDAsMC01OS42NGwtOC41Nyw5LjY3YTYsNiwwLDEsMS05LThsOC42OS05LjgxYTMuMDcsMy4wNywwLDAsMSwuMjUtLjI2QTU0LDU0LDAsMCwxLDI0NiwxMjhaIi8+PC9zdmc+');}.icon-arrow-counter-clockwise{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjIsMTI4YTk0LDk0LDAsMCwxLTkyLjc0LDk0SDEyOGE5My40Myw5My40MywwLDAsMS02NC41LTI1LjY1LDYsNiwwLDEsMSw4LjI0LTguNzJBODIsODIsMCwxLDAsNzAsNzBsLS4xOS4xOUwzOS40NCw5OEg3MmE2LDYsMCwwLDEsMCwxMkgyNGE2LDYsMCwwLDEtNi02VjU2YTYsNiwwLDAsMSwxMiwwVjkwLjM0TDYxLjYzLDYxLjRBOTQsOTQsMCwwLDEsMjIyLDEyOFoiLz48L3N2Zz4=');}.icon-magnifying-glass{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjguMjQsMjE5Ljc2bC01MS4zOC01MS4zOGE4Ni4xNSw4Ni4xNSwwLDEsMC04LjQ4LDguNDhsNTEuMzgsNTEuMzhhNiw2LDAsMCwwLDguNDgtOC40OFpNMzgsMTEyYTc0LDc0LDAsMSwxLDc0LDc0QTc0LjA5LDc0LjA5LDAsMCwxLDM4LDExMloiLz48L3N2Zz4=');}.icon-floppy-disk{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMTcuOSw3My40MiwxODIuNTgsMzguMWExMy45LDEzLjksMCwwLDAtOS44OS00LjFINDhBMTQsMTQsMCwwLDAsMzQsNDhWMjA4YTE0LDE0LDAsMCwwLDE0LDE0SDIwOGExNCwxNCwwLDAsMCwxNC0xNFY4My4zMUExMy45LDEzLjksMCwwLDAsMjE3LjksNzMuNDJaTTE3MCwyMTBIODZWMTUyYTIsMiwwLDAsMSwyLTJoODBhMiwyLDAsMCwxLDIsMlptNDAtMmEyLDIsMCwwLDEtMiwySDE4MlYxNTJhMTQsMTQsMCwwLDAtMTQtMTRIODhhMTQsMTQsMCwwLDAtMTQsMTR2NThINDhhMiwyLDAsMCwxLTItMlY0OGEyLDIsMCwwLDEsMi0ySDE3Mi42OWEyLDIsMCwwLDEsMS40MS41OEwyMDkuNDIsODEuOWEyLDIsMCwwLDEsLjU4LDEuNDFaTTE1OCw3MmE2LDYsMCwwLDEtNiw2SDk2YTYsNiwwLDAsMSwwLTEyaDU2QTYsNiwwLDAsMSwxNTgsNzJaIi8+PC9zdmc+');}.icon-calendar{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMDgsMzRIMTgyVjI0YTYsNiwwLDAsMC0xMiwwVjM0SDg2VjI0YTYsNiwwLDAsMC0xMiwwVjM0SDQ4QTE0LDE0LDAsMCwwLDM0LDQ4VjIwOGExNCwxNCwwLDAsMCwxNCwxNEgyMDhhMTQsMTQsMCwwLDAsMTQtMTRWNDhBMTQsMTQsMCwwLDAsMjA4LDM0Wk00OCw0Nkg3NFY1NmE2LDYsMCwwLDAsMTIsMFY0Nmg4NFY1NmE2LDYsMCwwLDAsMTIsMFY0NmgyNmEyLDIsMCwwLDEsMiwyVjgySDQ2VjQ4QTIsMiwwLDAsMSw0OCw0NlpNMjA4LDIxMEg0OGEyLDIsMCwwLDEtMi0yVjk0SDIxMFYyMDhBMiwyLDAsMCwxLDIwOCwyMTBabS05OC05MHY2NGE2LDYsMCwwLDEtMTIsMFYxMjkuNzFsLTcuMzIsMy42NmE2LDYsMCwxLDEtNS4zNi0xMC43NGwxNi04QTYsNiwwLDAsMSwxMTAsMTIwWm01OS41NywyOS4yNUwxNDgsMTc4aDIwYTYsNiwwLDAsMSwwLDEySDEzNmE2LDYsMCwwLDEtNC44LTkuNkwxNjAsMTQyYTEwLDEwLDAsMSwwLTE2LjY1LTExQTYsNiwwLDEsMSwxMzMsMTI1YTIyLDIyLDAsMSwxLDM2LjYyLDI0LjI2WiIvPjwvc3ZnPg==');}.icon-clock-clockwise{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMzQsODB2NDQuNmwzNy4wOSwyMi4yNWE2LDYsMCwwLDEtNi4xOCwxMC4zbC00MC0yNEE2LDYsMCwwLDEsMTIyLDEyOFY4MGE2LDYsMCwwLDEsMTIsMFptOTAtMjJhNiw2LDAsMCwwLTYsNlY4Ny4zNmMtNy40OC04LjgzLTE0Ljk0LTE3LjEzLTIzLjUzLTI1LjgzYTk0LDk0LDAsMSwwLTEuOTUsMTM0LjgzLDYsNiwwLDAsMC04LjI0LTguNzJBODIsODIsMCwxLDEsMTg2LDcwYzkuMjQsOS4zNiwxNy4xOCwxOC4zLDI1LjMxLDI4SDE4NGE2LDYsMCwwLDAsMCwxMmg0MGE2LDYsMCwwLDAsNi02VjY0QTYsNiwwLDAsMCwyMjQsNThaIi8+PC9zdmc+');}.icon-shuffle{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzYuMjQsMTc5Ljc2YTYsNiwwLDAsMSwwLDguNDhsLTI0LDI0YTYsNiwwLDAsMS04LjQ4LTguNDhMMjE3LjUyLDE5MEgyMDAuOTRhNzAuMTYsNzAuMTYsMCwwLDEtNTctMjkuMzFsLTQxLjcxLTU4LjRBNTguMTEsNTguMTEsMCwwLDAsNTUuMDYsNzhIMzJhNiw2LDAsMCwxLDAtMTJINTUuMDZhNzAuMTYsNzAuMTYsMCwwLDEsNTcsMjkuMzFsNDEuNzEsNTguNEE1OC4xMSw1OC4xMSwwLDAsMCwyMDAuOTQsMTc4aDE2LjU4bC0xMy43Ni0xMy43NmE2LDYsMCwwLDEsOC40OC04LjQ4Wm0tOTIuMDYtNzQuNDFhNS45MSw1LjkxLDAsMCwwLDMuNDgsMS4xMiw2LDYsMCwwLDAsNC44OS0yLjUxbDEuMTktMS42N0E1OC4xMSw1OC4xMSwwLDAsMSwyMDAuOTQsNzhoMTYuNThMMjAzLjc2LDkxLjc2YTYsNiwwLDEsMCw4LjQ4LDguNDhsMjQtMjRhNiw2LDAsMCwwLDAtOC40OGwtMjQtMjRhNiw2LDAsMCwwLTguNDgsOC40OEwyMTcuNTIsNjZIMjAwLjk0YTcwLjE2LDcwLjE2LDAsMCwwLTU3LDI5LjMxTDE0Mi43OCw5N0E2LDYsMCwwLDAsMTQ0LjE4LDEwNS4zNVptLTMyLjM2LDQ1LjNhNiw2LDAsMCwwLTguMzcsMS4zOWwtMS4xOSwxLjY3QTU4LjExLDU4LjExLDAsMCwxLDU1LjA2LDE3OEgzMmE2LDYsMCwwLDAsMCwxMkg1NS4wNmE3MC4xNiw3MC4xNiwwLDAsMCw1Ny0yOS4zMWwxLjE5LTEuNjdBNiw2LDAsMCwwLDExMS44MiwxNTAuNjVaIi8+PC9zdmc+');}.icon-sort-descending{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik00MiwxMjhhNiw2LDAsMCwxLDYtNmg3MmE2LDYsMCwwLDEsMCwxMkg0OEE2LDYsMCwwLDEsNDIsMTI4Wm02LTU4aDU2YTYsNiwwLDAsMCwwLTEySDQ4YTYsNiwwLDAsMCwwLDEyWk0xODQsMTg2SDQ4YTYsNiwwLDAsMCwwLDEySDE4NGE2LDYsMCwwLDAsMC0xMlpNMjI4LjI0LDgzLjc2bC00MC00MGE2LDYsMCwwLDAtOC40OCwwbC00MCw0MGE2LDYsMCwwLDAsOC40OCw4LjQ4TDE3OCw2Mi40OVYxNDRhNiw2LDAsMCwwLDEyLDBWNjIuNDlsMjkuNzYsMjkuNzVhNiw2LDAsMCwwLDguNDgtOC40OFoiLz48L3N2Zz4=');}.icon-sort-ascending{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMjYsMTI4YTYsNiwwLDAsMS02LDZINDhhNiw2LDAsMCwxLDAtMTJoNzJBNiw2LDAsMCwxLDEyNiwxMjhaTTQ4LDcwSDE4NGE2LDYsMCwwLDAsMC0xMkg0OGE2LDYsMCwwLDAsMCwxMlptNTYsMTE2SDQ4YTYsNiwwLDAsMCwwLDEyaDU2YTYsNiwwLDAsMCwwLTEyWm0xMjQuMjQtMjIuMjRhNiw2LDAsMCwwLTguNDgsMEwxOTAsMTkzLjUxVjExMmE2LDYsMCwwLDAtMTIsMHY4MS41MWwtMjkuNzYtMjkuNzVhNiw2LDAsMCwwLTguNDgsOC40OGw0MCw0MGE2LDYsMCwwLDAsOC40OCwwbDQwLTQwQTYsNiwwLDAsMCwyMjguMjQsMTYzLjc2WiIvPjwvc3ZnPg==');}.icon-arrow-elbow-left-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzgsNzJhNiw2LDAsMCwxLTYsNkg5NFYyMDEuNTFsMzcuNzYtMzcuNzVhNiw2LDAsMCwxLDguNDgsOC40OGwtNDgsNDhhNiw2LDAsMCwxLTguNDgsMGwtNDgtNDhhNiw2LDAsMCwxLDguNDgtOC40OEw4MiwyMDEuNTFWNzJhNiw2LDAsMCwxLDYtNkgyMzJBNiw2LDAsMCwxLDIzOCw3MloiLz48L3N2Zz4=');}.icon-arrow-elbow-right-down{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMjguMjQsMTY0LjI0bC00OCw0OGE2LDYsMCwwLDEtOC40OCwwbC00OC00OGE2LDYsMCwxLDEsOC40OC04LjQ4TDE3MCwxOTMuNTFWNzBIMzJhNiw2LDAsMCwxLDAtMTJIMTc2YTYsNiwwLDAsMSw2LDZWMTkzLjUxbDM3Ljc2LTM3Ljc1YTYsNiwwLDAsMSw4LjQ4LDguNDhaIi8+PC9zdmc+');}.icon-caret-right{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xODAuMjQsMTMyLjI0bC04MCw4MGE2LDYsMCwwLDEtOC40OC04LjQ4TDE2Ny41MSwxMjgsOTEuNzYsNTIuMjRhNiw2LDAsMCwxLDguNDgtOC40OGw4MCw4MEE2LDYsMCwwLDEsMTgwLjI0LDEzMi4yNFoiLz48L3N2Zz4=');}.icon-heart{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xNzgsNDJjLTIxLDAtMzkuMjYsOS40Ny01MCwyNS4zNEMxMTcuMjYsNTEuNDcsOTksNDIsNzgsNDJhNjAuMDcsNjAuMDcsMCwwLDAtNjAsNjBjMCwyOS4yLDE4LjIsNTkuNTksNTQuMSw5MC4zMWEzMzQuNjgsMzM0LjY4LDAsMCwwLDUzLjA2LDM3LDYsNiwwLDAsMCw1LjY4LDAsMzM0LjY4LDMzNC42OCwwLDAsMCw1My4wNi0zN0MyMTkuOCwxNjEuNTksMjM4LDEzMS4yLDIzOCwxMDJBNjAuMDcsNjAuMDcsMCwwLDAsMTc4LDQyWk0xMjgsMjE3LjExQzExMS41OSwyMDcuNjQsMzAsMTU3LjcyLDMwLDEwMkE0OC4wNSw0OC4wNSwwLDAsMSw3OCw1NGMyMC4yOCwwLDM3LjMxLDEwLjgzLDQ0LjQ1LDI4LjI3YTYsNiwwLDAsMCwxMS4xLDBDMTQwLjY5LDY0LjgzLDE1Ny43Miw1NCwxNzgsNTRhNDguMDUsNDguMDUsMCwwLDEsNDgsNDhDMjI2LDE1Ny43MiwxNDQuNDEsMjA3LjY0LDEyOCwyMTcuMTFaIi8+PC9zdmc+');}.icon-dots-three{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0xMzgsMTI4YTEwLDEwLDAsMSwxLTEwLTEwQTEwLDEwLDAsMCwxLDEzOCwxMjhaTTYwLDExOGExMCwxMCwwLDEsMCwxMCwxMEExMCwxMCwwLDAsMCw2MCwxMThabTEzNiwwYTEwLDEwLDAsMSwwLDEwLDEwQTEwLDEwLDAsMCwwLDE5NiwxMThaIi8+PC9zdmc+');}.icon-star-half-fi{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzkuMTgsOTcuMjZBMTYuMzgsMTYuMzgsMCwwLDAsMjI0LjkyLDg2bC01OS00Ljc2TDE0My4xNCwyNi4xNWExNi4zNiwxNi4zNiwwLDAsMC0zMC4yNywwTDkwLjExLDgxLjIzLDMxLjA4LDg2YTE2LjQ2LDE2LjQ2LDAsMCwwLTkuMzcsMjguODZsNDUsMzguODNMNTMsMjExLjc1YTE2LjQsMTYuNCwwLDAsMCwyNC41LDE3LjgyTDEyOCwxOTguNDlsNTAuNTMsMzEuMDhBMTYuNCwxNi40LDAsMCwwLDIwMywyMTEuNzVsLTEzLjc2LTU4LjA3LDQ1LTM4LjgzQTE2LjQzLDE2LjQzLDAsMCwwLDIzOS4xOCw5Ny4yNlptLTE1LjM0LDUuNDctNDguNyw0MmE4LDgsMCwwLDAtMi41Niw3LjkxbDE0Ljg4LDYyLjhhLjM3LjM3LDAsMCwxLS4xNy40OGMtLjE4LjE0LS4yMy4xMS0uMzgsMGwtNTQuNzItMzMuNjVBOCw4LDAsMCwwLDEyOCwxODEuMVYzMmMuMjQsMCwuMjcuMDguMzUuMjZMMTUzLDkxLjg2YTgsOCwwLDAsMCw2Ljc1LDQuOTJsNjMuOTEsNS4xNmMuMTYsMCwuMjUsMCwuMzQuMjlTMjI0LDEwMi42MywyMjMuODQsMTAyLjczWiIvPjwvc3ZnPg==');}.icon-star-fi{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yMzQuMjksMTE0Ljg1bC00NSwzOC44M0wyMDMsMjExLjc1YTE2LjQsMTYuNCwwLDAsMS0yNC41LDE3LjgyTDEyOCwxOTguNDksNzcuNDcsMjI5LjU3QTE2LjQsMTYuNCwwLDAsMSw1MywyMTEuNzVsMTMuNzYtNTguMDctNDUtMzguODNBMTYuNDYsMTYuNDYsMCwwLDEsMzEuMDgsODZsNTktNC43NiwyMi43Ni01NS4wOGExNi4zNiwxNi4zNiwwLDAsMSwzMC4yNywwbDIyLjc1LDU1LjA4LDU5LDQuNzZhMTYuNDYsMTYuNDYsMCwwLDEsOS4zNywyOC44NloiLz48L3N2Zz4=');}.icon-heart-fi{--icon:url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJjdXJyZW50Q29sb3IiPjxwYXRoIGQ9Ik0yNDAsMTAyYzAsNzAtMTAzLjc5LDEyNi42Ni0xMDguMjEsMTI5YTgsOCwwLDAsMS03LjU4LDBDMTE5Ljc5LDIyOC42NiwxNiwxNzIsMTYsMTAyQTYyLjA3LDYyLjA3LDAsMCwxLDc4LDQwYzIwLjY1LDAsMzguNzMsOC44OCw1MCwyMy44OUMxMzkuMjcsNDguODgsMTU3LjM1LDQwLDE3OCw0MEE2Mi4wNyw2Mi4wNywwLDAsMSwyNDAsMTAyWiIvPjwvc3ZnPg==');} |
| | |
| | | if (!item) return; |
| | | item.dataset.itemId.split(',').forEach(itemId => { |
| | | let field = this.forms.getField(e.target); |
| | | if (['repeater', 'tag-list'].includes(field.dataset.fieldType)) { |
| | | return; |
| | | } |
| | | let name = field.dataset.field; |
| | | let value = this.forms.getFieldValue(e.target); |
| | | this.updateItem(itemId, name, value); |
| | |
| | | 2000 |
| | | ); |
| | | } |
| | | |
| | | cancelBackup() { |
| | | window.debouncer.cancel(`changes-${this.content}`); |
| | | } |
| | |
| | | await this.handleBackup(); |
| | | } |
| | | const changes = await this.changesStore.getAll(); |
| | | |
| | | console.log('Saving Changes: ', changes); |
| | | if (changes.length === 0) return; |
| | | |
| | | if (title === '') { |
| | |
| | | }, |
| | | tagList: { |
| | | tagList: '.field.tag-list', //querySelectorAll |
| | | input: '.tag-input-row', |
| | | input: '.row', |
| | | add: '.add-tag', |
| | | remove: '.remove-tag', |
| | | label: '.tag-label', |
| | |
| | | if (e.target.closest('[data-ignore]') || this.isRestoring) return; |
| | | |
| | | let field = this.getField(e.target); |
| | | |
| | | //Dependencies |
| | | if (this.dependencies.has(field.dataset.field)) { |
| | | let dependency = this.dependencies.get(field.dataset.field); |
| | |
| | | }); |
| | | } |
| | | |
| | | if (Object.hasOwn(field.dataset, 'repeater-id') || Object.hasOwn(field.dataset,'tag-list-id')) { |
| | | this.updateCollectionField(field); |
| | | return; |
| | | } |
| | | |
| | | let form = this.getForm(e.target); |
| | | this.updateItem(field.dataset.field, this.getFieldValue(e.target), form); |
| | | } |
| | |
| | | |
| | | if (this.subscribers.size > 0) { |
| | | e.preventDefault(); |
| | | console.log('Cancelling scheduled backup and manually backing up'); |
| | | |
| | | |
| | | |
| | | if (form.options.cache) { |
| | | this.cancelBackup(); |
| | |
| | | this.removeQuantityListeners(item.element); |
| | | break; |
| | | } |
| | | |
| | | if (check.has(item.id)) { |
| | | check.delete(item.id); |
| | | } |
| | | }); |
| | | check.delete(item.id); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | checkForRepeaters(form) { |
| | | |
| | | if (!form.querySelector(this.selectors.repeater.repeater)) return; |
| | | |
| | | form.querySelectorAll(this.selectors.repeater.repeater).forEach(repeater => { |
| | |
| | | sortable: false, |
| | | }; |
| | | |
| | | if (!config.ui.addButton) return; |
| | | if (!config.ui.add) return; |
| | | |
| | | let template = repeater.querySelector('template'); |
| | | this.templates.define( |
| | |
| | | setup({el, refs, manyRefs, data}) { |
| | | let index = config.ui.items?.children?.length??0; |
| | | el.dataset.index = index; |
| | | |
| | | |
| | | manyRefs.inputs?.forEach(input => { |
| | | let wrapper = el.closest('[data-field]'); |
| | | window.prefixInput(input, `${el.dataset.fieldName}:${index}:`, wrapper); |
| | | window.prefixInput(input, `${data.repeater.dataset.fieldName}:${index}:`, el); |
| | | }); |
| | | } |
| | | }, |
| | |
| | | } |
| | | handleRepeaterClick(e) { |
| | | if (e.target.matches(this.selectors.repeater.add)) { |
| | | console.log('Add Repeater Row'); |
| | | this.addRepeaterRow(e.target.closest('[data-repeater-id]')); |
| | | } else if (e.target.matches(this.selectors.repeater.remove)) { |
| | | this.removeRepeaterRow(e.target); |
| | | console.log('Remove Repeater Row'); |
| | | this.removeRepeaterRow(e.target.closest('[data-index]')); |
| | | } |
| | | } |
| | | addRepeaterRow(repeater) { |
| | | repeater.append(this.templates.create(repeater.dataset.repeaterId)); |
| | | let data = {}; |
| | | data.repeater = repeater; |
| | | repeater.append(this.templates.create(repeater.dataset.repeaterId, data)); |
| | | this.initializeFields(repeater, this.getField(repeater).config??{}); |
| | | this.a11y.announce('Row added'); |
| | | } |
| | | removeRepeaterRow(row) { |
| | |
| | | config.ui.items.append(newItem); |
| | | config.ui.inputs[0]?.focus(); |
| | | |
| | | this.updateCollectionField(tagList); |
| | | |
| | | this.a11y.announce('Item added'); |
| | | } |
| | | removeTagListItem(tag) { |
| | |
| | | ); |
| | | }); |
| | | }); |
| | | |
| | | |
| | | this.updateCollectionField(container); |
| | | } |
| | | |
| | | /** |
| | | * Update the entire repeater/tagList field data |
| | | * Call this whenever rows are added, removed, or reordered |
| | | */ |
| | | updateCollectionField(element) { |
| | | const field = element.closest('[data-field]'); |
| | | if (!field) return; |
| | | |
| | | const fieldType = field.dataset.fieldType; |
| | | if (!['repeater', 'tag-list'].includes(fieldType)) return; |
| | | |
| | | const form = this.getForm(element); |
| | | if (!form) return; |
| | | |
| | | // Get all current data for the collection |
| | | const value = this.getFieldValue(field.querySelector('input, select, textarea')); |
| | | this.updateItem(field.dataset.field, value, form); |
| | | } |
| | | /********************************************************************** |
| | | VALIDATION |
| | |
| | | window.removeChildren(grid); |
| | | ids.forEach(id => { |
| | | let data = this.data.images[id]??{}; |
| | | data.field = { |
| | | config: { |
| | | showMeta: true |
| | | } |
| | | }; |
| | | data.id = id; |
| | | grid.append(this.templates.create('uploadItem', data)); |
| | | }); |
| | |
| | | }, |
| | | favourites: '.favourite-terms', |
| | | field: { |
| | | toggle: 'button.taxonomy-toggle, [data-filter="taxonomy"]', |
| | | toggle: 'button.selector-toggle, [data-filter="taxonomy"]', |
| | | value: 'input[type="hidden"]', |
| | | selected: '.selected-items', |
| | | dropdown: { |
| | |
| | | break; |
| | | } |
| | | if (refs.details) { |
| | | if (Object.hasOwn(data.field.config, 'showMeta') && !data.field.config.showMeta) { |
| | | if (Object.hasOwn(data, 'field') && Object.hasOwn(data.field,'config') && Object.hasOwn(data.field.config, 'showMeta') && !data.field.config.showMeta) { |
| | | refs.details.remove(); |
| | | } else { |
| | | if(Object.hasOwn(data, 'id')) { |
| | |
| | | h1: function() { this.quill.format('header', 1); }, |
| | | h2: function() { this.quill.format('header', 2); }, |
| | | h3: function() { this.quill.format('header', 3); }, |
| | | 'jvb_bold': function() {this.quill.format('bold', true)}, |
| | | 'jvb_italic': function() {this.quill.format('italic', true)}, |
| | | 'jvb_strike': function() {this.quill.format('strike', true)}, |
| | | 'jvb_underline': function() {this.quill.format('underline', true)}, |
| | | 'jvb_bold': function() { |
| | | const format = this.quill.getFormat(); |
| | | this.quill.format('bold', !format.bold); |
| | | }, |
| | | 'jvb_italic': function() { |
| | | const format = this.quill.getFormat(); |
| | | this.quill.format('italic', !format.italic); |
| | | }, |
| | | 'jvb_strike': function() { |
| | | const format = this.quill.getFormat(); |
| | | this.quill.format('strike', !format.strike); |
| | | }, |
| | | 'jvb_underline': function() { |
| | | const format = this.quill.getFormat(); |
| | | this.quill.format('underline', !format.underline); |
| | | }, |
| | | 'jvb_align': function(value) { |
| | | this.quill.format('align', value === this.quill.getFormat().list ? false : value); |
| | | const format = this.quill.getFormat(); |
| | | this.quill.format('align', value === format.align ? false : value); |
| | | }, |
| | | 'jvb_list': function(value) { |
| | | this.quill.format('list', value === this.quill.getFormat().list ? false : value); |
| | | // value will be either "bullet" or "ordered" depending on which button was clicked |
| | | const format = this.quill.getFormat(); |
| | | this.quill.format('list', value === format.list ? false : value); |
| | | }, |
| | | 'jvb_link': function(value) { |
| | | if (value) { |
| | |
| | | } |
| | | }, |
| | | 'jvb_image': function() { |
| | | const objectID = textarea.dataset.postId || textarea.closest('form')?.dataset.postId; |
| | | |
| | | const input = document.createElement('input'); |
| | | input.setAttribute('type', 'file'); |
| | | input.setAttribute('accept', 'image/jpeg,image/png,image/gif,image/webp'); |
| | |
| | | this.quill.insertEmbed(range.index, 'image', result.url); |
| | | |
| | | } catch (error) { |
| | | this.handleError('Upload error:', error); |
| | | console.error('Upload error:', error); |
| | | this.quill.insertText(range.index, 'Failed to upload image. Please try again.', { |
| | | 'color': '#f00', |
| | | 'italic': true |
| | |
| | | instances.push(quill); |
| | | |
| | | quill.on('selection-change', function(range) { |
| | | if (!range) return; |
| | | |
| | | const format = quill.getFormat(range); |
| | | |
| | | // Update button states |
| | | const formatButtons = { |
| | | 'ql-jvb_bold': 'bold', |
| | | 'ql-jvb_italic': 'italic', |
| | | 'ql-jvb_underline': 'underline', |
| | | 'ql-jvb_strike': 'strike' |
| | | }; |
| | | |
| | | Object.entries(formatButtons).forEach(([buttonClass, formatName]) => { |
| | | const button = toolbar.querySelector(`.${buttonClass}`); |
| | | if (button) { |
| | | button.classList.toggle('active', !!format[formatName]); |
| | | } |
| | | }); |
| | | |
| | | // Update list button states |
| | | toolbar.querySelectorAll('.ql-jvb_list').forEach(button => { |
| | | const value = button.getAttribute('value'); |
| | | button.classList.toggle('ql-active', format.list === value); |
| | | }); |
| | | |
| | | // Update alignment button states |
| | | toolbar.querySelectorAll('.ql-jvb_align').forEach(button => { |
| | | const value = button.getAttribute('value'); |
| | | button.classList.toggle('ql-active', format.align === value); |
| | | }); |
| | | |
| | | const alignmentTools = toolbar.querySelector('.ql-align'); |
| | | if (alignmentTools) { |
| | | if (range && range.length === 0) { |
| | | if (range.length === 0) { |
| | | // Get the focused element |
| | | const [leaf] = this.quill.getLeaf(range.index); |
| | | if (leaf && leaf.domNode && leaf.domNode.tagName === 'IMG') { |
| | |
| | | (()=>{class e{constructor(){this.container=document.querySelector(".crud[data-content]:not([data-ignore])"),this.container&&(this.content=this.container.dataset.content,this.endpoint=this.container.dataset.endpoint??"content",this.singular=this.container.dataset.singular,this.plural=this.container.dataset.plural,this.queue=window.jvbQueue,this.a11y=window.jvbA11y,this.error=window.jvbError,this.populate=window.jvbPopulate,this.cache=new window.jvbCache(this.content),this.activeItem=null,this.isTimeline=!1,this.isPopulating=!1,this.changes=new Map,this.items=new Map,this.init())}init(){this.initElements(),this.initListeners(),this.defineTemplates();let e=this.initSettings();this.initStore(e),this.checkHideFilters(),this.initIntegrations(),this.initUploader(),this.initModals()}defineTemplates(){const e=window.jvbTemplates,t=this,s=(e,s,i)=>{e.dataset.itemId=i.id;let a=s.checkbox.closest(".preview");window.prefixInput(s.checkbox,`select-${i.id}`,a,!0),s.checkbox.value=i.id,s.checkbox.checked=t.selected.has(parseInt(i.id)),s.selectLabel&&(s.selectLabel.htmlFor=`select-${i.id}`),s.edit&&(s.edit.dataset.id=i.id),s.trash&&(s.trash.dataset.id=i.id)},i=function(e,t,s){if(s?.fields?.post_thumbnail){const e=s.images[s.fields.post_thumbnail]??{};t.img.src=e.medium??"",t.img.alt=e.alt??s.fields.post_title??""}};e.define("gridView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},setup({el:e,refs:t,manyRefs:a,data:l}){s(e,t,l),i(0,t,l)}}),e.define("listView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},manyRefs:{attrs:"[data-attr]",fields:"[data-field]"},setup({el:e,refs:t,manyRefs:a,data:l}){s(e,t,l),i(0,t,l),a?.attrs?.forEach((e=>{const t=l[e.dataset.attr];t&&""!==t?e.textContent=t:e.remove()})),a?.fields?.forEach((e=>{const t=l.fields?.[e.dataset.field];t&&""!==t?"DIV"===e.tagName?e.innerHTML=t:e.textContent=t:e.remove()}))}});let a={};this.isTimeline&&(a.sharedRow="tr.shared",a.point="tr.timeline-point"),e.define("tableView",{refs:{checkbox:".select-item",selectLabel:"label.select-item-label",...a},manyRefs:{inputs:"input,select,textarea",status:'input[name="post_status"]',selectors:'[data-type="selector"]',fields:"[data-field]"},setup({el:e,refs:i,manyRefs:a,data:l}){if(s(e,i,l),a?.inputs?.forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${l.id}-`,t)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.isTimeline)i.sharedRow&&(i.sharedRow.querySelectorAll("input,select,textarea").forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${l.id}-`,t)})),t.populate.populate(i.sharedRow,l),i.sharedRow.querySelectorAll('input[name="post_status"]').forEach((e=>{e.value===l.status&&(e.checked=!0)}))),i.point&&l.fields?.timeline&&(Object.entries(l.fields.timeline).forEach((([s,a],n)=>{const o=i.point.cloneNode(!0);o.dataset.index=`${n}`,o.dataset.itemId=a.id,o.querySelectorAll("input,select,textarea").forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${a.id}-`,t)})),t.populate.populate(o,{fields:a,images:l.images,taxonomies:l.taxonomies});const d=l.images?.[a.post_thumbnail];d&&o.querySelector(".field.upload")?.setAttribute("title",d["image-title"]??""),e.insertBefore(o,i.point)})),i.point.remove());else if(void 0!==t.ui.table.form?.dataset.edit)a?.inputs?.forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${l.id}-`,t)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.populate.populate(e,l);else{const e=Object.hasOwn(l,"fields")?l.fields:l;a?.fields?.forEach((t=>{if(Object.hasOwn(e,t.dataset.field)&&""!==e[t.dataset.field]){let s=e[t.dataset.field],i=e.children[0];i&&(i.textContent="date"===t.dataset.field?window.formatTimeAgo(s):s)}}))}a?.selectors?.forEach((e=>e.setAttribute("data-lazy","")))}}),e.define("emptyState"),e.define("bulkItem",{refs:{checkbox:"input",img:"img",label:"label"},setup({el:e,refs:t,manyRefs:s,data:i}){t.checkbox&&(t.checkbox.id=`bulk_${i.id}`,t.checkbox.value=i.id,t.checkbox.checked=!0,t.checkbox.name="selected[]");let a=i?.images[i?.fields?.post_thumnbail]??{};t.img&&Object.keys(a).length>0&&(t.img.src=a.medium??"",t.img.alt=a.alt??""),t.label&&(t.label.title=item.fields.post_title)}}),e.define("trashOptions"),e.define("notTrashOptions"),e.define("contentTable")}initElements(){this.allowedFilters=["status","orderby","order","search","date-filter","dateFrom","dateTo"],this.selectors={buttons:{create:".create-item",clearFilters:'[data-action="clear-filters"]'},views:{grid:'input[data-view="grid"]',list:'input[data-view="list"]',table:'input[data-view="table"]'},modals:{create:{modal:"dialog.create",form:"dialog.create form",h2:"dialog.create h2"},edit:{modal:"dialog.edit",form:"dialog.edit form",h2:"dialog.edit h2"},bulkEdit:{modal:"dialog.bulkEdit",selected:"dialog.bulkEdit .selected",h2:"dialog.bulkEdit h2 span",form:"dialog.bulkEdit form"},date:{modal:"dialog.date-range",start:"dialog.date-range .date-start",end:"dialog.date-range .date-end",month:"dialog.date-range .month-select"}},grid:`.${this.content}.item-grid`,table:{nav:"#vertical",form:"form.table",table:"form.table table",body:"form.table body",head:"form.table thead",foot:"form.table tfoot",selectedColumns:".all-filters .multi-select",columns:"thead th"},bulk:{action:".bulk-action-select",count:".bulk-controls .selected-count",control:".bulk-controls .bulk-actions",select:".bulk-controls select",selectAll:".select-all"},filters:{container:"details.all-filters",search:'.all-filters input[type="search"]',status:{all:'[name="status"]#all',publish:'[name="status"]#publish',draft:'[name="status"]#draft',trash:'[name="status"]#trash'},orderby:{date:'[name="orderby"]#date',alphabetical:'[name="orderby"]#alphabetical'},order:{asc:'[name="order"][value="asc"]',desc:'[name="order"][value="desc"]'},date:'[data-filter="date"]'},uploader:"details.uploader"},this.ui=window.uiFromSelectors(this.selectors);const e=document.querySelectorAll('[data-filter="taxonomies"]');e.length>0&&(this.ui.filters.taxonomies={},e.forEach((e=>{const t=e.dataset.taxonomy;this.ui.filters.taxonomies[t]=e,this.allowedFilters.push(`tax_${t}`)}))),this.isTimeline=!!document.querySelector("[data-timeline]")}initUploader(){this.ui.uploader&&(window.jvbUploads.scanFields(this.ui.uploader),window.jvbUploads.subscribe(((e,t)=>{"sent-to-queue"===e&&t===this.ui.uploader.dataset.uploader&&window.debouncer.schedule("crud-complete",(()=>{this.store.clearCache()}))})))}initModals(){this.modals={};for(let[e,t]of Object.entries(this.ui.modals))t.modal&&(this.modals[e]=new window.jvbModal(t.modal),this.modals[e].subscribe(((t,s)=>{if("modal-close"===t){const t=this.ui.modals[e].form.dataset.formId;t&&this.forms.clearForm(t),this.resetForm(this.ui.modals[e].form),"date"===e&&this.handleCustomDateSelection(),["edit","bulkEdit","create"].includes(e)&&window.debouncer.timeouts.has(`save-${this.content}`)&&this.scheduleSave(0)}})))}initStore(e){let t={...this.defaults,...e};const s=window.jvbStore.register(this.content,[{storeName:this.content,keyPath:"id",endpoint:this.endpoint??"content",headers:{action_nonce:window.auth.getNonce("dash")},indexes:[{name:"id",keyPath:"id"},{name:"status",keyPath:"status"},{name:"date",keyPath:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"}],filters:t,ignore:["content","user"],TTL:36e5,showLoading:!0},{storeName:"changes",keyPath:"id"}]);this.changesStore=s.changes,this.store=s[this.content],this.store.subscribe(((e,t)=>{if("data-loaded"===e)this.render(),this.selectionHandler.collectItems()})),this.changesStore.subscribe(((e,t)=>{if("data-ready"===e){let e=this.changesStore.getAll();e.length>0&&(e.forEach((e=>{this.changes.set(e.id,e)})),this.savePosts("",!1).then((()=>{})))}}))}initIntegrations(){this.selected=new Set,this.selectionHandler=new window.jvbHandleSelection(this.container,{selectAll:{checkbox:"#select-all",label:".bulk-select label",span:".bulk-select label span"},wrapper:{wrapper:".wrap"},item:{idAttribute:"itemId"}}),this.selectionHandler.subscribe(((e,t)=>{this.selected=new Set([...t.selectedItems].map((e=>parseInt(e)))),this.ui.bulk.control.hidden=0===this.selected.size,this.ui.bulk.count.hidden=0===this.selected.size,this.ui.bulk.count.textContent=`${this.selected.size} ${this.plural} selected`})),this.forms=window.jvbForm,this.queue.subscribe(((e,t)=>{if(["image_upload","video_upload","document_upload"].includes(t.type)&&"operation-status"===e&&"completed"===t.status&&this.store.clearCache(),"operation-status"===e&&"completed"===t.status&&"uploads/groups"===t.endpoint&&(console.log("Cleared local cache. Refresh to see changes"),this.store.clearCache()),"operation-status"===e&&"completed"===t.status&&"content_update"===t.type){if(console.log("Cleared local cache. Refresh to see changes"),this.store.clearCache(),!t.result||!t.result.posts)return void console.warn("Content update completed but no result.posts",t);const e=Object.keys(t.result.posts).filter((e=>!0===t.result.posts[e]?.success));if(0===e.length)return;this.changesStore.deleteMany(e),e.forEach((e=>this.changes.delete(e)))}}))}initSettings(){this.defaults={content:this.content,user:window.auth.getUser(),page:1,status:"all",orderby:"date",order:"desc",search:""};let e={},t=this.container.dataset.view??"grid";this.view=this.cache.get("view")??t,this.view!==t&&(this.ui.views[this.view].checked=!0),this.status=this.cache.get("status")??this.defaults.status,this.status!==this.defaults.status&&(this.ui.filters.status[this.status].checked=!0,e.status=this.status),this.orderby=this.cache.get("orderby")??this.defaults.orderby,this.orderby!==this.defaults.orderby&&(this.ui.filters.orderby[this.orderby].checked=!0,e.orderBy=this.orderby),this.order=this.cache.get("order")??this.defaults.order,this.order!==this.defaults.order&&(this.ui.filters.order[this.order].checked=!0,e.order=this.order),this.ui.filters.taxonomies&&Object.entries(this.ui.filters.taxonomies).forEach((([t,s])=>{const i=`tax_${t}`,a=this.cache.get(i);a&&(s.value=a,e[i]=a)}));let s=this.cache.get("tabNav")??"horizontal";this.ui.table.nav&&"vertical"===s&&(this.ui.table.nav.checked=!0);let i={showFilters:{element:this.ui.filters.container,default:"closed"},showUploader:{element:this.ui.uploader,default:"open"}};for(let[e,t]of Object.entries(i))if(t.element){let s=this.cache.get(e)??t.default;t.element.open="open"===s,t.element.addEventListener("toggle",(()=>{this.cache.set(e,t.element.open?"open":"closed")}))}return e}initListeners(){this.changeHandler=this.handleChange.bind(this),this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleModalSubmit.bind(this),document.addEventListener("change",this.changeHandler),document.addEventListener("click",this.clickHandler),this.ui.filters.search&&this.ui.filters.search.addEventListener("input",this.inputHandler);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.addEventListener("submit",this.submitHandler)}handleModalSubmit(e){e.preventDefault();const t=e.target.closest("dialog");if(!t)return;let s=`Saving changes for multiple ${this.plural}`;t.classList.contains("edit")?s="Saving your edits...":t.classList.contains("create")&&(s=`Creating your new ${this.singular}`),this.scheduleSave(0)}handleChange(e){const t=e.target.closest("[data-item-id]"),s=e.target.matches("[data-filter]"),i=e.target.matches(".bulk-action-select"),a=e.target.matches("[data-view]");if(t||s||i||a)if(this.isPopulating||!t||e.target.closest("[data-ignore], .select-item")){if(a)return this.items.clear(),void this.handleViewChange(e.target);if(i)this.handleBulkAction(e.target);else if(s)this.handleFilterChange(e.target);else if("table"===this.view){if(e.target.matches("details.multi-select"))return void this.toggleColumn(e.target.id,e.target.checked);e.target.matches(this.selectors.table.nav)&&(this.tabNav=e.target.checked,this.cache.set("tabNav",e.target.checked?"vertical":"horizontal"))}}else this.handleItemUpdate(e)}handleBulkAction(e){if(e.value.startsWith("tax-")){const t=e.options[e.selectedIndex],s=t.dataset.taxonomy,i=t.dataset.single,a=t.dataset.plural;return window.jvbSelector.openEmpty(s,i,a,(e=>this.handleBulkTaxonomy(e))),void(e.value="")}switch(e.value){case"edit":this.openBulkEditModal();break;case"publish":case"trash":case"delete":this.setBulkStatus(e.value);break;case"draft":case"restore":this.setBulkStatus("draft")}}handleBulkTaxonomy(e){e.termIds.length&&this.selected.size&&(this.selected.forEach((t=>{const s=this.store.get(t);if(!s)return;const i=(s.taxonomies?.[e.taxonomy]||[]).map((e=>e.id)),a=[...new Set([...i,...e.termIds])];this.updateItem(t,e.taxonomy,a)})),this.savePosts(`Adding ${e.terms.length} ${e.taxonomy} to ${this.selected.size} ${this.plural}...`).then((()=>{})),this.selectionHandler.clearSelection())}handleItemUpdate(e){let t=window.targetCheck(e,"[data-item-id]");t&&t.dataset.itemId.split(",").forEach((t=>{let s=this.forms.getField(e.target).dataset.field,i=this.forms.getFieldValue(e.target);this.updateItem(t,s,i)}))}updateItem(e,t,s){this.changes.has(e)||this.changes.set(e,{id:e,content:this.content}),this.changes.get(e)[t]=s,this.scheduleBackup(),this.scheduleSave()}scheduleBackup(){window.debouncer.schedule(`changes-${this.content}`,(async()=>{this.changes.size>0&&await this.handleBackup()}),2e3)}cancelBackup(){window.debouncer.cancel(`changes-${this.content}`)}async handleBackup(){const e=Array.from(this.changes.values());this.changes.clear();const t=e.map((e=>e.id)),s=await Promise.all(t.map((e=>this.changesStore.get(e)))),i=e.map(((e,t)=>s[t]?window.deepMerge(s[t],e):e));await this.changesStore.saveMany(i)}scheduleSave(e=1e4){window.debouncer.schedule(`save-${this.content}`,(async()=>{this.changes.size>0&&(this.cancelBackup(),await this.handleBackup()),await this.savePosts("",!1)}),e)}handleFilterChange(e){let t=e.dataset.filter;return"date"===t&&"custom"===e.value?(e.value="",void this.modals.date.handleOpen()):"date"===t&&""!==e.value?(this.setFilter("date-filter",e.value),this.deleteFilter("dateFrom"),this.deleteFilter("dateTo"),void this.checkHideFilters()):("taxonomies"===t&&(t=`tax_${e.dataset.taxonomy}`),void this.setFilter(t,e.value))}checkHideFilters(){const e=this.store.filters,t=Object.entries(e).some((([e,t])=>!["content","user","page"].includes(e)&&(this.defaults[e]!==t&&""!==t&&null!==t)));this.ui.buttons.clearFilters.hidden=!t}clearAllFilters(){let e=this.store.filters;this.store.clearFilters();for(let[t,s]of Object.entries(e))this.cache.remove(t),this.deleteFilter(t,s);this.a11y.announce("All filters cleared")}handleCustomDateSelection(){if(this.ui.modals.date.month&&this.ui.modals.date.month.value){const[e,t]=this.ui.modals.date.month.value.split("-"),s=`${e}-${t}-01`,i=new Date(e,parseInt(t),0).getDate(),a=`${e}-${t}-${String(i).padStart(2,"0")}`;this.setFilter("dateFrom",s),this.setFilter("dateTo",a),this.deleteFilter("date-filter"),this.ui.modals.date.month.value=""}else this.ui.modals.date.start&&this.ui.modals.date.start.value&&this.ui.modals.date.end&&this.ui.modals.date.end.value&&(this.setFilter("dateFrom",this.ui.modals.date.start.value),this.setFilter("dateTo",this.ui.modals.date.end.value),this.deleteFilter("date-filter"),this.ui.modals.date.start.value="",this.ui.modals.date.end.value="");this.checkHideFilters()}handleViewChange(e){this.view=e.dataset.view,this.cache.set("view",this.view),this.render()}handleClick(e){if(e.target.matches(".clear-search"))return void this.deleteFilter("search","");const t=e.target.closest("[data-action]");return t?(e.preventDefault(),void this.handleActionButton(t)):e.target.matches(".apply-date-filter")?(this.handleCustomDateSelection(),void this.modals.date.handleClose()):void(e.target.matches(this.selectors.buttons.create)&&this.openCreateModal())}openCreateModal(){this.forms.registerForm(this.ui.modals.create.form,{cache:!1}),this.ui.modals.create.modal.dataset.itemId=window.generateID("new"),this.modals.create.handleOpen()}handleActionButton(e){const t=e.dataset.id;switch(e.dataset.action){case"edit":this.openEditModal(t);break;case"delete":confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t));break;case"trash":"trash"===this.status?confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t)):(this.updateItem(t,"post_status","trash"),window.fade(e.closest(".item"),!1),this.savePosts(`Sending ${this.singular} to trash...`).then((()=>{})));break;case"bulk-edit":this.selected.size>0&&this.openBulkEditModal();break;case"bulk-delete":this.handleBulkDelete();break;case"refresh":this.store.clearCache(),this.store.fetch();break;case"clear-filters":this.clearAllFilters()}}handleBulkDelete(){let e="trash"===this.status;if(this.selected.size>0&&confirm(`${e?"Permanently delete":"Send"} ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}${e?"":"to trash"}?`)){this.selected.forEach((t=>{this.store.delete(t),this.updateItem(t,"post_status",e?"delete":"trash")}));let t=e?`Permanently deleting ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}`:`Sending ${this.selected.size} ${1===this.selected.size?this.singular:this.plural} to trash`;this.savePosts(t).then((()=>{})),this.selectionHandler.clearSelection()}}handleInput(e){e.preventDefault(),e.stopPropagation();let t=e.target.value.trim(),s=`${this.content}-search`;0!==t.length?window.debouncer.schedule(s,(()=>{this.a11y.announce(`Searching for "${t}"...`),this.store.setFilters({search:t,page:1})}),300):this.deleteFilter("search","")}handleKeys(e){if(this.tabNav&&"Tab"===e.key){e.preventDefault();const t=e.target.closest("[data-field]"),s=e.target.closest("tr");if(!t||!s)return;const i=t.dataset.field,a=e.shiftKey;let l=this.findNextEditableRow(s,a);l||(l=this.wrapToRow(s,a)),l&&this.focusFieldInRow(l,i,a)}}findNextEditableRow(e,t=!1){let s=t?e.previousElementSibling:e.nextElementSibling;for(;s&&!this.isEditableRow(s);)s=t?s.previousElementSibling:s.nextElementSibling;return s}wrapToRow(e,t=!1){if(this.isTimeline){const s=e.closest("tbody");if(!s)return null;const i=Array.from(s.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?i[i.length-1]:i[0]}{if(!this.ui.table.body)return null;const e=Array.from(this.ui.table.body.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?e[e.length-1]:e[0]}}isEditableRow(e){return!e.closest("thead")&&!e.closest("tfoot")&&(this.isTimeline?e.classList.contains("shared")||e.classList.contains("timeline-point"):!!e.dataset.itemId)}focusFieldInRow(e,t,s=!1){const i=e.querySelector(`[data-field="${t}"]`);if(!i)return;const a=this.findFocusableInput(i);if(a){a.focus(),a.select&&"text"===a.type&&a.select();const e=s?"next":"previous";this.a11y?.announce(`Moved to ${t} in ${e} row`)}}findFocusableInput(e){const t=['input:not([type="hidden"]):not([disabled])',"textarea:not([disabled])","select:not([disabled])","button:not([disabled])"];for(const s of t){const t=e.querySelector(s);if(t)return t}return null}openEditModal(e){let t=this.store.get(parseInt(e));t&&(this.activeItem=t.id,this.ui.modals.edit.modal.dataset.itemId=e,this.ui.modals.edit.modal.dataset.content=this.content,this.ui.modals.edit.h2.textContent=`Editing ${""===t.fields.post_title?this.singular:t.fields.post_title}`,this.ui.modals.edit.form.dataset.formId=`edit-${e}`,this.forms.registerForm(this.ui.modals.edit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,t),this.isPopulating=!1,this.modals.edit.handleOpen())}openBulkEditModal(){window.removeChildren(this.ui.modals.bulkEdit.selected),this.ui.modals.edit.form.reset(),window.chunkIt(this.selected,(t=>{let s=this.store.get(parseInt(t));if(s)return e.push(s.id),window.jvbTemplates.create("bulkItem",s)}),(e=>this.ui.modals.bulkEdit.selected.append(e))).then((()=>{}));let e=Array.from(this.selected).map((e=>this.store.get(parseInt(e)))).filter(Boolean);this.ui.modals.bulkEdit.modal.dataset.itemId=e.join(","),this.ui.modals.bulkEdit.h2&&(this.ui.modals.bulkEdit.h2.textContent=this.selected.size),this.modals.bulkEdit.handleOpen(),this.forms.registerForm(this.ui.modals.bulkEdit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,item),this.isPopulating=!1}async savePosts(e="",t=!1){this.changes.size>0&&(this.cancelBackup(),await this.handleBackup());const s=await this.changesStore.getAll();if(0===s.length)return;""===e&&(e=`Saving ${s.length} ${1===s.length?this.singular:this.plural}`);let i={},a=[];s.forEach((e=>{let t=e.id;const{id:s,...l}=e;i[t]=l,e.post_status&&this.shouldRemoveItemUI(e.post_status)&&a.push(t)})),a.length>0&&this.removeItems(a);let l={endpoint:this.endpoint,headers:{action_nonce:window.auth.getNonce("dash")},data:{posts:i},delay:t,popup:"Saving changes",title:e};this.queue.addToQueue(l)}setBulkStatus(e){if(!["publish","draft","trash","delete"].includes(e))return;let t,s=[];if(this.selected.forEach((t=>{s.push(t),this.updateItem(t,"post_status",e)})),"delete"===e)t="Deleting";else t=window.uppercaseFirst(e)+"ing";this.shouldRemoveItemUI(e)&&this.removeItems(s),this.selectionHandler.clearSelection(),this.savePosts(`${t} ${s.length} ${1===s.length?this.singular:this.plural}...`).then((()=>{}))}render(){const e=this.store.getFiltered();if(0!==e.length){switch(this.view){case"grid":this.renderGrid(e);break;case"table":this.renderTable(e).then((()=>{}));break;case"list":this.renderList(e)}this.updateUI()}else this.renderEmpty()}updateUI(){if(this.ui.bulk.action){let e=!1,t=this.ui.bulk.action.querySelector('[value="edit"]'),s=this.status;"trash"===s&&t?(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("trashOptions")):"trash"===s||t||(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("notTrashOptions")),e&&e.querySelectorAll("option").forEach(((e,t)=>{0===t&&(e.checked=!0),this.ui.bulk.action.append(e)})),this.ui.bulk.action.value=""}this.selected.size>0&&this.selectionHandler.updateSelectionUI()}renderEmpty(){this.toggleTable(!1),window.removeChildren(this.ui.grid);const e=window.jvbTemplates.create("emptyState");e&&(this.ui.grid.append(e),this.a11y.announceItems(0,!1,!1))}toggleTable(e=!0){if(this.ui.table.selectedColumns&&(this.ui.table.selectedColumns.hidden=!e),e&&!this.ui.table.form){let e=window.jvbTemplates.create("contentTable");this.container.append(e),this.ui.table=window.uiFromSelectors(this.selectors.table),this.ui.table.columns=this.container.querySelectorAll(this.selectors.table.columns)}this.ui.table.form&&(this.ui.table.form.hidden=!e,e||this.forms.clearForm(this.ui.table.form.dataset.formId),this.ui.table.body&&window.removeChildren(this.ui.table.body)),this.keyHandler=this.handleKeys.bind(this),e?document.addEventListener("keydown",this.keyHandler):document.removeEventListener("keydown",this.keyHandler)}renderGrid(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("list-view"),this.ui.grid.classList.add("grid-view"),window.chunkIt(e,(e=>this.renderGridItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}renderList(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("grid-view"),this.ui.grid.classList.add("list-view"),window.chunkIt(e,(e=>this.renderListItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}async renderTable(e){this.toggleTable(),window.removeChildren(this.ui.grid),await window.chunkIt(e,(e=>this.renderTableItem(e)),(e=>{this.ui.table.body?this.ui.table.body.append(e):this.ui.table.table.insertBefore(e,this.ui.table.foot)}),5),requestAnimationFrame((()=>{window.jvbSelector?.scanExistingFields(this.ui.table.table)}))}renderGridItem(e){let t=window.jvbTemplates.create("gridView",e);return this.items.set(e.id,t),t}renderListItem(e){let t=window.jvbTemplates.create("listView",e);return this.items.set(e.id,t),t}renderTableItem(e){let t=window.jvbTemplates.create("tableView",e);return this.items.set(e.id,t),t}toggleColumn(e,t){this.ui.table.table.querySelectorAll(`.${e}`).forEach((e=>{e.hidden=!t}))}shouldRemoveItemUI(e){return"all"===this.status&&!["publish","draft"].includes(e)||e!==this.store.filters.status}removeItems(e){e.forEach((e=>{if(this.items.has(e)){let t=this.items.get(e);t&&window.fade(t,!1)}}))}setFilters(e){for(let[t,s]of Object.entries(e)){if(!this.allowedFilters.includes(t)){delete e[t];continue}this.cache.set(t,s);let i=this.findFilterEl(t);this.setElValue(i,s)}this.store.setFilters(e)}setFilter(e,t){if(!this.allowedFilters.includes(e))return;this.cache.set(e,t),"status"===e&&(this.status=t),"orderby"===e&&(this.orderby=t),"order"===e&&(this.order=t);let s=this.findFilterEl(e,t);this.setElValue(s,t),this.store.setFilter(e,t)}deleteFilter(e,t){if(!this.allowedFilters.includes(e))return;if(Object.hasOwn(this.defaults,e))return void this.setFilter(e,this.defaults[e]);let s=this.findFilterEl(e,t);this.setElValue(s,!1),this.cache.remove(e),this.setFilter(e,"")}setElValue(e,t){if(e){if(!t)return["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=""),["text","search"].includes(e.type)&&(e.value=""),void("radio"===e.type&&(e.checked=!1));["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=t),["text","search"].includes(e.type)&&(e.value=t),"radio"===e.type&&(e.checked=!0)}}findFilterEl(e,t){if(["date-filter","dateFrom","dateTo"].includes(e)){switch(e){case"date-filter":e="month";break;case"dateFrom":e="start";break;case"dateTo":e="end"}return this.ui.modals.date[e]}if(e.includes("tax_")){const t=e.replace("tax_",""),s=this.ui.filters.taxonomies?.[t];return s||(console.warn("Taxonomy filter element not found:",t),null)}if(!Object.hasOwn(this.ui.filters,e))return console.warn("Filter el not found: ",e),!1;let s=this.ui.filters[e];if("object"==typeof s){if(!Object.hasOwn(this.ui.filters[e],t))return!1;s=this.ui.filters[e][t]}return s}resetForm(e){e.querySelectorAll('input[type="hidden"], input[type="text"], input[type="number"], input[type="email"], input[type="url"], textarea').forEach((e=>{e.value=""})),e.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((e=>{e.checked=!1})),e.querySelectorAll("select").forEach((e=>{e.selectedIndex=0})),e.querySelectorAll(".selected-items").forEach((e=>{window.removeChildren(e)})),e.querySelectorAll(".item-grid.preview").forEach((e=>{window.removeChildren(e)}))}destroy(){window.debouncer.cancel(`changes-${this.content}`),this.changes.size>0&&(this.changesStore.saveMany(this.changes).then((()=>{})),this.changes.clear()),this.timelineSortables&&(this.timelineSortables.forEach((e=>e.destroy())),this.timelineSortables=[]);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.removeEventListener("submit",this.submitHandler);document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),this.ui.filters.search&&this.ui.filters.search.removeEventListener("input",this.handleInput)}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{if("auth-loaded"===t){let t=document.querySelector("[data-content]");t&&!Object.hasOwn(t.dataset,"ignore")&&(window.crudManager=new e({content:t.dataset.content}))}}))}))})(); |
| | | (()=>{class e{constructor(){this.container=document.querySelector(".crud[data-content]:not([data-ignore])"),this.container&&(this.content=this.container.dataset.content,this.endpoint=this.container.dataset.endpoint??"content",this.singular=this.container.dataset.singular,this.plural=this.container.dataset.plural,this.queue=window.jvbQueue,this.a11y=window.jvbA11y,this.error=window.jvbError,this.populate=window.jvbPopulate,this.cache=new window.jvbCache(this.content),this.activeItem=null,this.isTimeline=!1,this.isPopulating=!1,this.changes=new Map,this.items=new Map,this.init())}init(){this.initElements(),this.initListeners(),this.defineTemplates();let e=this.initSettings();this.initStore(e),this.checkHideFilters(),this.initIntegrations(),this.initUploader(),this.initModals()}defineTemplates(){const e=window.jvbTemplates,t=this,s=(e,s,i)=>{e.dataset.itemId=i.id;let a=s.checkbox.closest(".preview");window.prefixInput(s.checkbox,`select-${i.id}`,a,!0),s.checkbox.value=i.id,s.checkbox.checked=t.selected.has(parseInt(i.id)),s.selectLabel&&(s.selectLabel.htmlFor=`select-${i.id}`),s.edit&&(s.edit.dataset.id=i.id),s.trash&&(s.trash.dataset.id=i.id)},i=function(e,t,s){if(s?.fields?.post_thumbnail){const e=s.images[s.fields.post_thumbnail]??{};t.img.src=e.medium??"",t.img.alt=e.alt??s.fields.post_title??""}};e.define("gridView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},setup({el:e,refs:t,manyRefs:a,data:l}){s(e,t,l),i(0,t,l)}}),e.define("listView",{refs:{img:"img",checkbox:".select-item",selectLabel:"label.select-item-label",edit:'[data-action="edit"]',trash:'[data-action="trash"]'},manyRefs:{attrs:"[data-attr]",fields:"[data-field]"},setup({el:e,refs:t,manyRefs:a,data:l}){s(e,t,l),i(0,t,l),a?.attrs?.forEach((e=>{const t=l[e.dataset.attr];t&&""!==t?e.textContent=t:e.remove()})),a?.fields?.forEach((e=>{const t=l.fields?.[e.dataset.field];t&&""!==t?"DIV"===e.tagName?e.innerHTML=t:e.textContent=t:e.remove()}))}});let a={};this.isTimeline&&(a.sharedRow="tr.shared",a.point="tr.timeline-point"),e.define("tableView",{refs:{checkbox:".select-item",selectLabel:"label.select-item-label",...a},manyRefs:{inputs:"input,select,textarea",status:'input[name="post_status"]',selectors:'[data-type="selector"]',fields:"[data-field]"},setup({el:e,refs:i,manyRefs:a,data:l}){if(s(e,i,l),a?.inputs?.forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${l.id}-`,t)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.isTimeline)i.sharedRow&&(i.sharedRow.querySelectorAll("input,select,textarea").forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${l.id}-`,t)})),t.populate.populate(i.sharedRow,l),i.sharedRow.querySelectorAll('input[name="post_status"]').forEach((e=>{e.value===l.status&&(e.checked=!0)}))),i.point&&l.fields?.timeline&&(Object.entries(l.fields.timeline).forEach((([s,a],n)=>{const o=i.point.cloneNode(!0);o.dataset.index=`${n}`,o.dataset.itemId=a.id,o.querySelectorAll("input,select,textarea").forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${a.id}-`,t)})),t.populate.populate(o,{fields:a,images:l.images,taxonomies:l.taxonomies});const d=l.images?.[a.post_thumbnail];d&&o.querySelector(".field.upload")?.setAttribute("title",d["image-title"]??""),e.insertBefore(o,i.point)})),i.point.remove());else if(void 0!==t.ui.table.form?.dataset.edit)a?.inputs?.forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${l.id}-`,t)})),a?.status?.forEach((e=>{e.value===l.status&&(e.checked=!0)})),t.populate.populate(e,l);else{const e=Object.hasOwn(l,"fields")?l.fields:l;a?.fields?.forEach((t=>{if(Object.hasOwn(e,t.dataset.field)&&""!==e[t.dataset.field]){let s=e[t.dataset.field],i=e.children[0];i&&(i.textContent="date"===t.dataset.field?window.formatTimeAgo(s):s)}}))}a?.selectors?.forEach((e=>e.setAttribute("data-lazy","")))}}),e.define("emptyState"),e.define("bulkItem",{refs:{checkbox:"input",img:"img",label:"label"},setup({el:e,refs:t,manyRefs:s,data:i}){t.checkbox&&(t.checkbox.id=`bulk_${i.id}`,t.checkbox.value=i.id,t.checkbox.checked=!0,t.checkbox.name="selected[]");let a=i?.images[i?.fields?.post_thumnbail]??{};t.img&&Object.keys(a).length>0&&(t.img.src=a.medium??"",t.img.alt=a.alt??""),t.label&&(t.label.title=item.fields.post_title)}}),e.define("trashOptions"),e.define("notTrashOptions"),e.define("contentTable")}initElements(){this.allowedFilters=["status","orderby","order","search","date-filter","dateFrom","dateTo"],this.selectors={buttons:{create:".create-item",clearFilters:'[data-action="clear-filters"]'},views:{grid:'input[data-view="grid"]',list:'input[data-view="list"]',table:'input[data-view="table"]'},modals:{create:{modal:"dialog.create",form:"dialog.create form",h2:"dialog.create h2"},edit:{modal:"dialog.edit",form:"dialog.edit form",h2:"dialog.edit h2"},bulkEdit:{modal:"dialog.bulkEdit",selected:"dialog.bulkEdit .selected",h2:"dialog.bulkEdit h2 span",form:"dialog.bulkEdit form"},date:{modal:"dialog.date-range",start:"dialog.date-range .date-start",end:"dialog.date-range .date-end",month:"dialog.date-range .month-select"}},grid:`.${this.content}.item-grid`,table:{nav:"#vertical",form:"form.table",table:"form.table table",body:"form.table body",head:"form.table thead",foot:"form.table tfoot",selectedColumns:".all-filters .multi-select",columns:"thead th"},bulk:{action:".bulk-action-select",count:".bulk-controls .selected-count",control:".bulk-controls .bulk-actions",select:".bulk-controls select",selectAll:".select-all"},filters:{container:"details.all-filters",search:'.all-filters input[type="search"]',status:{all:'[name="status"]#all',publish:'[name="status"]#publish',draft:'[name="status"]#draft',trash:'[name="status"]#trash'},orderby:{date:'[name="orderby"]#date',alphabetical:'[name="orderby"]#alphabetical'},order:{asc:'[name="order"][value="asc"]',desc:'[name="order"][value="desc"]'},date:'[data-filter="date"]'},uploader:"details.uploader"},this.ui=window.uiFromSelectors(this.selectors);const e=document.querySelectorAll('[data-filter="taxonomies"]');e.length>0&&(this.ui.filters.taxonomies={},e.forEach((e=>{const t=e.dataset.taxonomy;this.ui.filters.taxonomies[t]=e,this.allowedFilters.push(`tax_${t}`)}))),this.isTimeline=!!document.querySelector("[data-timeline]")}initUploader(){this.ui.uploader&&(window.jvbUploads.scanFields(this.ui.uploader),window.jvbUploads.subscribe(((e,t)=>{"sent-to-queue"===e&&t===this.ui.uploader.dataset.uploader&&window.debouncer.schedule("crud-complete",(()=>{this.store.clearCache()}))})))}initModals(){this.modals={};for(let[e,t]of Object.entries(this.ui.modals))t.modal&&(this.modals[e]=new window.jvbModal(t.modal),this.modals[e].subscribe(((t,s)=>{if("modal-close"===t){const t=this.ui.modals[e].form.dataset.formId;t&&this.forms.clearForm(t),this.resetForm(this.ui.modals[e].form),"date"===e&&this.handleCustomDateSelection(),["edit","bulkEdit","create"].includes(e)&&window.debouncer.timeouts.has(`save-${this.content}`)&&this.scheduleSave(0)}})))}initStore(e){let t={...this.defaults,...e};const s=window.jvbStore.register(this.content,[{storeName:this.content,keyPath:"id",endpoint:this.endpoint??"content",headers:{action_nonce:window.auth.getNonce("dash")},indexes:[{name:"id",keyPath:"id"},{name:"status",keyPath:"status"},{name:"date",keyPath:"date"},{name:"modified",keyPath:"modified"},{name:"title",keyPath:"title"}],filters:t,ignore:["content","user"],TTL:36e5,showLoading:!0},{storeName:"changes",keyPath:"id"}]);this.changesStore=s.changes,this.store=s[this.content],this.store.subscribe(((e,t)=>{if("data-loaded"===e)this.render(),this.selectionHandler.collectItems()})),this.changesStore.subscribe(((e,t)=>{if("data-ready"===e){let e=this.changesStore.getAll();e.length>0&&(e.forEach((e=>{this.changes.set(e.id,e)})),this.savePosts("",!1).then((()=>{})))}}))}initIntegrations(){this.selected=new Set,this.selectionHandler=new window.jvbHandleSelection(this.container,{selectAll:{checkbox:"#select-all",label:".bulk-select label",span:".bulk-select label span"},wrapper:{wrapper:".wrap"},item:{idAttribute:"itemId"}}),this.selectionHandler.subscribe(((e,t)=>{this.selected=new Set([...t.selectedItems].map((e=>parseInt(e)))),this.ui.bulk.control.hidden=0===this.selected.size,this.ui.bulk.count.hidden=0===this.selected.size,this.ui.bulk.count.textContent=`${this.selected.size} ${this.plural} selected`})),this.forms=window.jvbForm,this.queue.subscribe(((e,t)=>{if(["image_upload","video_upload","document_upload"].includes(t.type)&&"operation-status"===e&&"completed"===t.status&&this.store.clearCache(),"operation-status"===e&&"completed"===t.status&&"uploads/groups"===t.endpoint&&(console.log("Cleared local cache. Refresh to see changes"),this.store.clearCache()),"operation-status"===e&&"completed"===t.status&&"content_update"===t.type){if(console.log("Cleared local cache. Refresh to see changes"),this.store.clearCache(),!t.result||!t.result.posts)return void console.warn("Content update completed but no result.posts",t);const e=Object.keys(t.result.posts).filter((e=>!0===t.result.posts[e]?.success));if(0===e.length)return;this.changesStore.deleteMany(e),e.forEach((e=>this.changes.delete(e)))}}))}initSettings(){this.defaults={content:this.content,user:window.auth.getUser(),page:1,status:"all",orderby:"date",order:"desc",search:""};let e={},t=this.container.dataset.view??"grid";this.view=this.cache.get("view")??t,this.view!==t&&(this.ui.views[this.view].checked=!0),this.status=this.cache.get("status")??this.defaults.status,this.status!==this.defaults.status&&(this.ui.filters.status[this.status].checked=!0,e.status=this.status),this.orderby=this.cache.get("orderby")??this.defaults.orderby,this.orderby!==this.defaults.orderby&&(this.ui.filters.orderby[this.orderby].checked=!0,e.orderBy=this.orderby),this.order=this.cache.get("order")??this.defaults.order,this.order!==this.defaults.order&&(this.ui.filters.order[this.order].checked=!0,e.order=this.order),this.ui.filters.taxonomies&&Object.entries(this.ui.filters.taxonomies).forEach((([t,s])=>{const i=`tax_${t}`,a=this.cache.get(i);a&&(s.value=a,e[i]=a)}));let s=this.cache.get("tabNav")??"horizontal";this.ui.table.nav&&"vertical"===s&&(this.ui.table.nav.checked=!0);let i={showFilters:{element:this.ui.filters.container,default:"closed"},showUploader:{element:this.ui.uploader,default:"open"}};for(let[e,t]of Object.entries(i))if(t.element){let s=this.cache.get(e)??t.default;t.element.open="open"===s,t.element.addEventListener("toggle",(()=>{this.cache.set(e,t.element.open?"open":"closed")}))}return e}initListeners(){this.changeHandler=this.handleChange.bind(this),this.clickHandler=this.handleClick.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleModalSubmit.bind(this),document.addEventListener("change",this.changeHandler),document.addEventListener("click",this.clickHandler),this.ui.filters.search&&this.ui.filters.search.addEventListener("input",this.inputHandler);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.addEventListener("submit",this.submitHandler)}handleModalSubmit(e){e.preventDefault();const t=e.target.closest("dialog");if(!t)return;let s=`Saving changes for multiple ${this.plural}`;t.classList.contains("edit")?s="Saving your edits...":t.classList.contains("create")&&(s=`Creating your new ${this.singular}`),this.scheduleSave(0)}handleChange(e){const t=e.target.closest("[data-item-id]"),s=e.target.matches("[data-filter]"),i=e.target.matches(".bulk-action-select"),a=e.target.matches("[data-view]");if(t||s||i||a)if(this.isPopulating||!t||e.target.closest("[data-ignore], .select-item")){if(a)return this.items.clear(),void this.handleViewChange(e.target);if(i)this.handleBulkAction(e.target);else if(s)this.handleFilterChange(e.target);else if("table"===this.view){if(e.target.matches("details.multi-select"))return void this.toggleColumn(e.target.id,e.target.checked);e.target.matches(this.selectors.table.nav)&&(this.tabNav=e.target.checked,this.cache.set("tabNav",e.target.checked?"vertical":"horizontal"))}}else this.handleItemUpdate(e)}handleBulkAction(e){if(e.value.startsWith("tax-")){const t=e.options[e.selectedIndex],s=t.dataset.taxonomy,i=t.dataset.single,a=t.dataset.plural;return window.jvbSelector.openEmpty(s,i,a,(e=>this.handleBulkTaxonomy(e))),void(e.value="")}switch(e.value){case"edit":this.openBulkEditModal();break;case"publish":case"trash":case"delete":this.setBulkStatus(e.value);break;case"draft":case"restore":this.setBulkStatus("draft")}}handleBulkTaxonomy(e){e.termIds.length&&this.selected.size&&(this.selected.forEach((t=>{const s=this.store.get(t);if(!s)return;const i=(s.taxonomies?.[e.taxonomy]||[]).map((e=>e.id)),a=[...new Set([...i,...e.termIds])];this.updateItem(t,e.taxonomy,a)})),this.savePosts(`Adding ${e.terms.length} ${e.taxonomy} to ${this.selected.size} ${this.plural}...`).then((()=>{})),this.selectionHandler.clearSelection())}handleItemUpdate(e){let t=window.targetCheck(e,"[data-item-id]");t&&t.dataset.itemId.split(",").forEach((t=>{let s=this.forms.getField(e.target);if(["repeater","tag-list"].includes(s.dataset.fieldType))return;let i=s.dataset.field,a=this.forms.getFieldValue(e.target);this.updateItem(t,i,a)}))}updateItem(e,t,s){this.changes.has(e)||this.changes.set(e,{id:e,content:this.content}),this.changes.get(e)[t]=s,this.scheduleBackup(),this.scheduleSave()}scheduleBackup(){window.debouncer.schedule(`changes-${this.content}`,(async()=>{this.changes.size>0&&await this.handleBackup()}),2e3)}cancelBackup(){window.debouncer.cancel(`changes-${this.content}`)}async handleBackup(){const e=Array.from(this.changes.values());this.changes.clear();const t=e.map((e=>e.id)),s=await Promise.all(t.map((e=>this.changesStore.get(e)))),i=e.map(((e,t)=>s[t]?window.deepMerge(s[t],e):e));await this.changesStore.saveMany(i)}scheduleSave(e=1e4){window.debouncer.schedule(`save-${this.content}`,(async()=>{this.changes.size>0&&(this.cancelBackup(),await this.handleBackup()),await this.savePosts("",!1)}),e)}handleFilterChange(e){let t=e.dataset.filter;return"date"===t&&"custom"===e.value?(e.value="",void this.modals.date.handleOpen()):"date"===t&&""!==e.value?(this.setFilter("date-filter",e.value),this.deleteFilter("dateFrom"),this.deleteFilter("dateTo"),void this.checkHideFilters()):("taxonomies"===t&&(t=`tax_${e.dataset.taxonomy}`),void this.setFilter(t,e.value))}checkHideFilters(){const e=this.store.filters,t=Object.entries(e).some((([e,t])=>!["content","user","page"].includes(e)&&(this.defaults[e]!==t&&""!==t&&null!==t)));this.ui.buttons.clearFilters.hidden=!t}clearAllFilters(){let e=this.store.filters;this.store.clearFilters();for(let[t,s]of Object.entries(e))this.cache.remove(t),this.deleteFilter(t,s);this.a11y.announce("All filters cleared")}handleCustomDateSelection(){if(this.ui.modals.date.month&&this.ui.modals.date.month.value){const[e,t]=this.ui.modals.date.month.value.split("-"),s=`${e}-${t}-01`,i=new Date(e,parseInt(t),0).getDate(),a=`${e}-${t}-${String(i).padStart(2,"0")}`;this.setFilter("dateFrom",s),this.setFilter("dateTo",a),this.deleteFilter("date-filter"),this.ui.modals.date.month.value=""}else this.ui.modals.date.start&&this.ui.modals.date.start.value&&this.ui.modals.date.end&&this.ui.modals.date.end.value&&(this.setFilter("dateFrom",this.ui.modals.date.start.value),this.setFilter("dateTo",this.ui.modals.date.end.value),this.deleteFilter("date-filter"),this.ui.modals.date.start.value="",this.ui.modals.date.end.value="");this.checkHideFilters()}handleViewChange(e){this.view=e.dataset.view,this.cache.set("view",this.view),this.render()}handleClick(e){if(e.target.matches(".clear-search"))return void this.deleteFilter("search","");const t=e.target.closest("[data-action]");return t?(e.preventDefault(),void this.handleActionButton(t)):e.target.matches(".apply-date-filter")?(this.handleCustomDateSelection(),void this.modals.date.handleClose()):void(e.target.matches(this.selectors.buttons.create)&&this.openCreateModal())}openCreateModal(){this.forms.registerForm(this.ui.modals.create.form,{cache:!1}),this.ui.modals.create.modal.dataset.itemId=window.generateID("new"),this.modals.create.handleOpen()}handleActionButton(e){const t=e.dataset.id;switch(e.dataset.action){case"edit":this.openEditModal(t);break;case"delete":confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t));break;case"trash":"trash"===this.status?confirm("Delete this item? This cannot be undone")&&(this.updateItem(t,"post_status","delete"),window.fade(e.closest(".item"),!1),this.savePosts(`Permanently deleting ${this.singular}...`).then((()=>{})),this.store.delete(t)):(this.updateItem(t,"post_status","trash"),window.fade(e.closest(".item"),!1),this.savePosts(`Sending ${this.singular} to trash...`).then((()=>{})));break;case"bulk-edit":this.selected.size>0&&this.openBulkEditModal();break;case"bulk-delete":this.handleBulkDelete();break;case"refresh":this.store.clearCache(),this.store.fetch();break;case"clear-filters":this.clearAllFilters()}}handleBulkDelete(){let e="trash"===this.status;if(this.selected.size>0&&confirm(`${e?"Permanently delete":"Send"} ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}${e?"":"to trash"}?`)){this.selected.forEach((t=>{this.store.delete(t),this.updateItem(t,"post_status",e?"delete":"trash")}));let t=e?`Permanently deleting ${this.selected.size} ${1===this.selected.size?this.singular:this.plural}`:`Sending ${this.selected.size} ${1===this.selected.size?this.singular:this.plural} to trash`;this.savePosts(t).then((()=>{})),this.selectionHandler.clearSelection()}}handleInput(e){e.preventDefault(),e.stopPropagation();let t=e.target.value.trim(),s=`${this.content}-search`;0!==t.length?window.debouncer.schedule(s,(()=>{this.a11y.announce(`Searching for "${t}"...`),this.store.setFilters({search:t,page:1})}),300):this.deleteFilter("search","")}handleKeys(e){if(this.tabNav&&"Tab"===e.key){e.preventDefault();const t=e.target.closest("[data-field]"),s=e.target.closest("tr");if(!t||!s)return;const i=t.dataset.field,a=e.shiftKey;let l=this.findNextEditableRow(s,a);l||(l=this.wrapToRow(s,a)),l&&this.focusFieldInRow(l,i,a)}}findNextEditableRow(e,t=!1){let s=t?e.previousElementSibling:e.nextElementSibling;for(;s&&!this.isEditableRow(s);)s=t?s.previousElementSibling:s.nextElementSibling;return s}wrapToRow(e,t=!1){if(this.isTimeline){const s=e.closest("tbody");if(!s)return null;const i=Array.from(s.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?i[i.length-1]:i[0]}{if(!this.ui.table.body)return null;const e=Array.from(this.ui.table.body.querySelectorAll("tr")).filter((e=>this.isEditableRow(e)));return t?e[e.length-1]:e[0]}}isEditableRow(e){return!e.closest("thead")&&!e.closest("tfoot")&&(this.isTimeline?e.classList.contains("shared")||e.classList.contains("timeline-point"):!!e.dataset.itemId)}focusFieldInRow(e,t,s=!1){const i=e.querySelector(`[data-field="${t}"]`);if(!i)return;const a=this.findFocusableInput(i);if(a){a.focus(),a.select&&"text"===a.type&&a.select();const e=s?"next":"previous";this.a11y?.announce(`Moved to ${t} in ${e} row`)}}findFocusableInput(e){const t=['input:not([type="hidden"]):not([disabled])',"textarea:not([disabled])","select:not([disabled])","button:not([disabled])"];for(const s of t){const t=e.querySelector(s);if(t)return t}return null}openEditModal(e){let t=this.store.get(parseInt(e));t&&(this.activeItem=t.id,this.ui.modals.edit.modal.dataset.itemId=e,this.ui.modals.edit.modal.dataset.content=this.content,this.ui.modals.edit.h2.textContent=`Editing ${""===t.fields.post_title?this.singular:t.fields.post_title}`,this.ui.modals.edit.form.dataset.formId=`edit-${e}`,this.forms.registerForm(this.ui.modals.edit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,t),this.isPopulating=!1,this.modals.edit.handleOpen())}openBulkEditModal(){window.removeChildren(this.ui.modals.bulkEdit.selected),this.ui.modals.edit.form.reset(),window.chunkIt(this.selected,(t=>{let s=this.store.get(parseInt(t));if(s)return e.push(s.id),window.jvbTemplates.create("bulkItem",s)}),(e=>this.ui.modals.bulkEdit.selected.append(e))).then((()=>{}));let e=Array.from(this.selected).map((e=>this.store.get(parseInt(e)))).filter(Boolean);this.ui.modals.bulkEdit.modal.dataset.itemId=e.join(","),this.ui.modals.bulkEdit.h2&&(this.ui.modals.bulkEdit.h2.textContent=this.selected.size),this.modals.bulkEdit.handleOpen(),this.forms.registerForm(this.ui.modals.bulkEdit.form,{cache:!1}),this.isPopulating=!0,this.populate.populate(this.ui.modals.edit.form,item),this.isPopulating=!1}async savePosts(e="",t=!1){this.changes.size>0&&(this.cancelBackup(),await this.handleBackup());const s=await this.changesStore.getAll();if(console.log("Saving Changes: ",s),0===s.length)return;""===e&&(e=`Saving ${s.length} ${1===s.length?this.singular:this.plural}`);let i={},a=[];s.forEach((e=>{let t=e.id;const{id:s,...l}=e;i[t]=l,e.post_status&&this.shouldRemoveItemUI(e.post_status)&&a.push(t)})),a.length>0&&this.removeItems(a);let l={endpoint:this.endpoint,headers:{action_nonce:window.auth.getNonce("dash")},data:{posts:i},delay:t,popup:"Saving changes",title:e};this.queue.addToQueue(l)}setBulkStatus(e){if(!["publish","draft","trash","delete"].includes(e))return;let t,s=[];if(this.selected.forEach((t=>{s.push(t),this.updateItem(t,"post_status",e)})),"delete"===e)t="Deleting";else t=window.uppercaseFirst(e)+"ing";this.shouldRemoveItemUI(e)&&this.removeItems(s),this.selectionHandler.clearSelection(),this.savePosts(`${t} ${s.length} ${1===s.length?this.singular:this.plural}...`).then((()=>{}))}render(){const e=this.store.getFiltered();if(0!==e.length){switch(this.view){case"grid":this.renderGrid(e);break;case"table":this.renderTable(e).then((()=>{}));break;case"list":this.renderList(e)}this.updateUI()}else this.renderEmpty()}updateUI(){if(this.ui.bulk.action){let e=!1,t=this.ui.bulk.action.querySelector('[value="edit"]'),s=this.status;"trash"===s&&t?(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("trashOptions")):"trash"===s||t||(window.removeChildren(this.ui.bulk.action),e=window.jvbTemplates.create("notTrashOptions")),e&&e.querySelectorAll("option").forEach(((e,t)=>{0===t&&(e.checked=!0),this.ui.bulk.action.append(e)})),this.ui.bulk.action.value=""}this.selected.size>0&&this.selectionHandler.updateSelectionUI()}renderEmpty(){this.toggleTable(!1),window.removeChildren(this.ui.grid);const e=window.jvbTemplates.create("emptyState");e&&(this.ui.grid.append(e),this.a11y.announceItems(0,!1,!1))}toggleTable(e=!0){if(this.ui.table.selectedColumns&&(this.ui.table.selectedColumns.hidden=!e),e&&!this.ui.table.form){let e=window.jvbTemplates.create("contentTable");this.container.append(e),this.ui.table=window.uiFromSelectors(this.selectors.table),this.ui.table.columns=this.container.querySelectorAll(this.selectors.table.columns)}this.ui.table.form&&(this.ui.table.form.hidden=!e,e||this.forms.clearForm(this.ui.table.form.dataset.formId),this.ui.table.body&&window.removeChildren(this.ui.table.body)),this.keyHandler=this.handleKeys.bind(this),e?document.addEventListener("keydown",this.keyHandler):document.removeEventListener("keydown",this.keyHandler)}renderGrid(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("list-view"),this.ui.grid.classList.add("grid-view"),window.chunkIt(e,(e=>this.renderGridItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}renderList(e){window.removeChildren(this.ui.grid),this.toggleTable(!1),this.ui.grid.classList.remove("grid-view"),this.ui.grid.classList.add("list-view"),window.chunkIt(e,(e=>this.renderListItem(e)),(e=>this.ui.grid.append(e))).then((()=>{}))}async renderTable(e){this.toggleTable(),window.removeChildren(this.ui.grid),await window.chunkIt(e,(e=>this.renderTableItem(e)),(e=>{this.ui.table.body?this.ui.table.body.append(e):this.ui.table.table.insertBefore(e,this.ui.table.foot)}),5),requestAnimationFrame((()=>{window.jvbSelector?.scanExistingFields(this.ui.table.table)}))}renderGridItem(e){let t=window.jvbTemplates.create("gridView",e);return this.items.set(e.id,t),t}renderListItem(e){let t=window.jvbTemplates.create("listView",e);return this.items.set(e.id,t),t}renderTableItem(e){let t=window.jvbTemplates.create("tableView",e);return this.items.set(e.id,t),t}toggleColumn(e,t){this.ui.table.table.querySelectorAll(`.${e}`).forEach((e=>{e.hidden=!t}))}shouldRemoveItemUI(e){return"all"===this.status&&!["publish","draft"].includes(e)||e!==this.store.filters.status}removeItems(e){e.forEach((e=>{if(this.items.has(e)){let t=this.items.get(e);t&&window.fade(t,!1)}}))}setFilters(e){for(let[t,s]of Object.entries(e)){if(!this.allowedFilters.includes(t)){delete e[t];continue}this.cache.set(t,s);let i=this.findFilterEl(t);this.setElValue(i,s)}this.store.setFilters(e)}setFilter(e,t){if(!this.allowedFilters.includes(e))return;this.cache.set(e,t),"status"===e&&(this.status=t),"orderby"===e&&(this.orderby=t),"order"===e&&(this.order=t);let s=this.findFilterEl(e,t);this.setElValue(s,t),this.store.setFilter(e,t)}deleteFilter(e,t){if(!this.allowedFilters.includes(e))return;if(Object.hasOwn(this.defaults,e))return void this.setFilter(e,this.defaults[e]);let s=this.findFilterEl(e,t);this.setElValue(s,!1),this.cache.remove(e),this.setFilter(e,"")}setElValue(e,t){if(e){if(!t)return["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=""),["text","search"].includes(e.type)&&(e.value=""),void("radio"===e.type&&(e.checked=!1));["SELECT","TEXTAREA"].includes(e.tagName)&&(e.value=t),["text","search"].includes(e.type)&&(e.value=t),"radio"===e.type&&(e.checked=!0)}}findFilterEl(e,t){if(["date-filter","dateFrom","dateTo"].includes(e)){switch(e){case"date-filter":e="month";break;case"dateFrom":e="start";break;case"dateTo":e="end"}return this.ui.modals.date[e]}if(e.includes("tax_")){const t=e.replace("tax_",""),s=this.ui.filters.taxonomies?.[t];return s||(console.warn("Taxonomy filter element not found:",t),null)}if(!Object.hasOwn(this.ui.filters,e))return console.warn("Filter el not found: ",e),!1;let s=this.ui.filters[e];if("object"==typeof s){if(!Object.hasOwn(this.ui.filters[e],t))return!1;s=this.ui.filters[e][t]}return s}resetForm(e){e.querySelectorAll('input[type="hidden"], input[type="text"], input[type="number"], input[type="email"], input[type="url"], textarea').forEach((e=>{e.value=""})),e.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((e=>{e.checked=!1})),e.querySelectorAll("select").forEach((e=>{e.selectedIndex=0})),e.querySelectorAll(".selected-items").forEach((e=>{window.removeChildren(e)})),e.querySelectorAll(".item-grid.preview").forEach((e=>{window.removeChildren(e)}))}destroy(){window.debouncer.cancel(`changes-${this.content}`),this.changes.size>0&&(this.changesStore.saveMany(this.changes).then((()=>{})),this.changes.clear()),this.timelineSortables&&(this.timelineSortables.forEach((e=>e.destroy())),this.timelineSortables=[]);for(let[e,t]of Object.entries(this.ui.modals))t.form&&t.form.removeEventListener("submit",this.submitHandler);document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),this.ui.filters.search&&this.ui.filters.search.removeEventListener("input",this.handleInput)}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{if("auth-loaded"===t){let t=document.querySelector("[data-content]");t&&!Object.hasOwn(t.dataset,"ignore")&&(window.crudManager=new e({content:t.dataset.content}))}}))}))})(); |
| | |
| | | (()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.queue=window.jvbQueue,this.populate=window.jvbPopulate,this.changes=new Map,this.forms=new Map,this.inputs=new Map,this.repeaters=new Map,this.tagLists=new Map,this.charLimits=new Map,this.quantityFields=new Map,this.quillInstances=new Map,this.dependencies=new Map,this.subscribers=new Set,this.isRestoring=!1,this.hasListeners=!1,this.summaryTemplate=!1,this.init()}init(){this.templates=window.jvbTemplates,this.defineSummaryTemplate(),this.initElements(),this.initListeners(),this.initStore(),this.initValidators()}initElements(){this.inputSelectors="input, textarea, select",this.selectors={tabs:{nav:"nav.tabs",sections:".tab.content",progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},buttons:"nav.tabs button"},dependsOn:"[data-depends-on]",forms:{status:{status:".fstatus",message:".fstatus .message",icon:".fstatus .icon",actions:".fstatus .actions"}},inputs:this.inputSelectors,fields:{field:".field",label:"label",success:".success",error:".success",message:".validation-message"},repeater:{repeater:".repeater",header:".repeater-row-header",remove:".remove-row",add:".add-repeater-row",template:"template",items:".repeater-items",inputs:this.inputSelectors},tagList:{tagList:".field.tag-list",input:".tag-input-row",add:".add-tag",remove:".remove-tag",label:".tag-label",items:".tag-items",inputs:this.inputSelectors,value:'input[type="hidden"]'},tag:{label:".tag-label"},number:{number:".field div.quantity",increase:"button.increase",decrease:"button.decrease",input:'input[type="number"]'},limits:{hasLimit:"[data-limit]",limit:".limit",current:".current"}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.blurHandler=this.handleBlur.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.quantityClick=this.handleQuantityClick.bind(this),this.repeaterClick=this.handleRepeaterClick.bind(this),this.tagListClick=this.handleTagListClick.bind(this),this.tagListInput=this.handleTagListInput.bind(this)}addFormListeners(e){e.addEventListener("click",this.clickHandler),e.addEventListener("change",this.changeHandler),e.addEventListener("input",this.inputHandler),e.addEventListener("blur",this.blurHandler),e.addEventListener("submit",this.submitHandler)}removeFormListeners(e){e.removeEventListener("click",this.clickHandler),e.removeEventListener("change",this.changeHandler),e.removeEventListener("input",this.inputHandler),e.removeEventListener("blur",this.blurHandler),e.removeEventListener("submit",this.submitHandler)}initStore(){const e=window.jvbStore.register("forms",{storeName:"forms",keyPath:"id",indexes:[{name:"src",keyPath:"src"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:1008e4});this.store=e.forms,this.store.subscribe(((e,t)=>{if("data-ready"===e){let e=this.store.getFiltered().filter((e=>e.src===window.location.pathname));for(let t of e)this.showPendingNotification(t.id,t.changes)}else"operation-status"===e&&"completed"===t.status&&t.config&&this.store.delete(t.config.id)}))}showPendingNotification(e,t){let s=this.forms.get(e);if(!s)return;let i=s.element;if(!i)return void console.warn(`Form element not found for: ${e}`);const a=document.createElement("div");a.className="pendingChanges",a.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n <button class="restore" type="button" data-form-id="${e}">Restore</button>\n <button class="discard" type="button" data-form-id="${e}">Discard</button>`,i.insertBefore(a,s.ui.status.status),a.querySelector(".restore").addEventListener("click",(async()=>{this.isRestoring=!0;let e={fields:t};this.populate.populate(i,e),this.a11y.announce("Previous changes restored"),this.isRestoring=!1,a.remove()})),a.querySelector(".discard").addEventListener("click",(async()=>{await this.store.delete(e),this.a11y.announce("Previous changes discarded"),a.remove()}))}initValidators(){this.validators={email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with https://"},phone:{pattern:/^[\d\s\-+().]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const s=parseFloat(e);if(isNaN(s))return"Please enter a valid number";const i=t.dataset.min,a=t.dataset.max;return void 0!==i&&s<parseFloat(i)?`Value must be at least ${i}`:!(void 0!==a&&s>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const s=t.dataset.minlength,i=t.dataset.maxlength;return s&&e.length<parseInt(s)?`Must be at least ${s} characters`:!(i&&e.length>parseInt(i))||`Must be no more than ${i} characters`}}}}validateField(e){const t=this.performValidation(e);return this.updateValidationUI(e,t),t.isValid}performValidation(e){const t=e.closest(".field"),s=this.getFieldCheckedValue(e);if(!s&&!e.required)return{isValid:!0,message:""};if(e.required)if("checkbox"===e.type){if(!e.checked)return{isValid:!1,message:"This field is required"}}else if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`);if(!Array.from(t).some((e=>e.checked)))return{isValid:!1,message:"Please select an option"}}else if(!s)return{isValid:!1,message:"This field is required"};if(e.checkValidity&&!e.checkValidity())return{isValid:!1,message:e.validationMessage};if(s&&Object.hasOwn(t.dataset,"pattern")){if(!new RegExp(t.dataset.pattern).test(s))return{isValid:!1,message:t.dataset.validationMessage||"Invalid format"}}if(Object.hasOwn(t.dataset,"validate")||e.type){const i=this.validators[t.dataset.validate||e.type];if(i&&i.pattern&&!i.pattern.test(s))return{isValid:!1,message:i.message};if(i&&i.test){const e=i.test(s,t);if(!0!==e)return{isValid:!1,message:e}}}return{isValid:!0,message:""}}updateValidationUI(e,t){t.isValid?this.showSuccess(e,t.message):this.showError(e,t.message)}handleClick(e){let t=this.getForm(e.target);if(!t)return;const s=window.targetCheck(e,"[data-action]");if(s){switch(s.dataset.action){case"clear-form":this.store.delete(t.id),t.element.reset(),t.ui.status.status.hidden=!0,this.a11y.announce("Form cleared, starting fresh");break;case"dismiss-restore":t.ui.status.status.hidden=!0}}}handleChange(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getField(e.target);if(this.dependencies.has(t.dataset.field)){this.dependencies.get(t.dataset.field).items.forEach((e=>{this.checkFieldDependency(e,t.dataset.field)}))}let s=this.getForm(e.target);this.updateItem(t.dataset.field,this.getFieldValue(e.target),s)}handleBlur(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target).dataset.field;window.debouncer.cancel(`form:${t.id}:validate:${s}`),this.validateField(e.target),this.updateItem(s,this.getFieldValue(e.target),t)}handleInput(e){let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target);if(!s)return;const i=e.target,a=s.dataset.field;this.showFormStatus(t.id,"pending"),window.debouncer.schedule(`form:${t.id}:validate:${a}`,(()=>this.validateField(i)),500)}async handleSubmit(e){let t=this.getForm(e.target);if(t){if(this.subscribers.size>0)if(e.preventDefault(),console.log("Cancelling scheduled backup and manually backing up"),t.options.cache){this.cancelBackup(),await this.backup();const e=await this.store.get(t.id);this.notify("form-submit",{config:t,data:e.changes})}else this.notify("form-submit",{config:t,data:this.changes.get(t.id)?.changes??{}});if(t.options.showSummary){const e=await this.store.get(t.id);this.showSummary({config:t,changes:e?.changes})}}}updateItem(e,t,s){this.changes.has(s.id)||this.changes.set(s.id,{id:s.id,timestamp:Date.now(),src:window.location.pathname,changes:{}});let i=this.changes.get(s.id);i.changes[e]=t,this.changes.set(s.id,i),s.options.cache&&this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule("form_changes",(async()=>{this.changes.size>0&&await this.backup()}),2e3)}cancelBackup(){window.debouncer.cancel("form_changes")}async backup(){const e=new Map;for(let[t,s]of this.changes.entries()){const i=await this.store.get(t);i?e.set(t,{...i,...s,changes:{...i.changes,...s.changes},timestamp:Date.now()}):e.set(t,s)}await this.store.saveMany(e);for(let e of this.changes.keys())this.showFormStatus(e,"autosaved");this.changes.clear()}saveCache(e){if(!this.changes.has(e))return;let t=this.changes.get(e);0!==t.size&&(this.store.save(t).then((()=>{})),this.changes.delete(e))}registerForm(e,t){if(Object.hasOwn(e.dataset,"formId")&&this.forms.has(e.dataset.formId))return;Object.hasOwn(e.dataset,"formId")||(e.dataset.formId=window.generateID("form_"));const s=e.dataset.formId;this.addFormListeners(e);const i={element:e,id:s,status:"",options:{autoUpload:t.autoUpload??!1,imageMeta:t.imageMeta??!0,delay:t.delay??1500,endpoint:t.save??e.dataset.save??"",showStatus:t.showStatus??!0,showSummary:t.showSummary??!1,cache:t.cache??!0,ignore:t.ignore??[]},ui:window.uiFromSelectors(this.selectors.forms,e)};return this.initializeFields(e,i),this.forms.set(s,i),i}clearForm(e){const t=this.forms.get(e);if(!t)return;t.unsubscribeTabs&&t.unsubscribeTabs(),t.tabs&&window.jvbTabs.removeTab(t.element),t.cache&&this.changes.has(e)&&this.saveCache(e);for(let[t,s]of this.inputs.entries())s.form===e&&this.inputs.delete(t);if(this.dependencies.forEach(((t,s)=>{t.items=t.items.filter((t=>t.form!==e)),0===t.items.length&&this.dependencies.delete(s)})),Object.hasOwn(t,"hasQuill")&&this.quillInstances.has(e)){this.quillInstances.get(e).forEach((e=>{e.disable(),e.off("text-change"),e.off("selection-change");const t=e.container.parentElement,s=t?.querySelector(".ql-toolbar");if(s&&s.remove(),e.setText(""),t&&t.classList.contains("editor-container")){const e=t.nextElementSibling;"TEXTAREA"===e?.tagName&&(e.style.display=""),t.remove()}})),this.quillInstances.delete(e)}let s={repeater:this.repeaters,tagList:this.tagLists,charLimit:this.charLimits,quantity:this.quantityFields};for(let[t,i]of Object.entries(s)){if(0===i.size)continue;let s=Array.from(i.values()).filter((t=>t.form===e));s.length>0&&(s.forEach((e=>{switch(t){case"repeater":this.removeRepeaterListeners(e.element);break;case"tagList":this.removeTagListListeners(e.element);break;case"charLimit":this.removeCharacterLimitListeners(e.element);break;case"quantity":this.removeQuantityListeners(e.element)}})),i.delete(item.id))}this.removeFormListeners(t.element),this.forms.delete(e),window.debouncer.cancel("form_changes")}defineSummaryTemplate(){this.summaryTemplate=!0;let e=this;this.templates.define("formSummary",{refs:{result:".result",h3:"h3",p:"p"},setup({el:t,refs:s,manyRefs:i,data:a}){const r=["sendAll",...a.config.options.ignore??[]];for(let[i,n]of Object.entries(a.changes)){if(r.includes(i)||e.isEmptyValue(n))continue;let a=Array.from(e.inputs.values()).find((e=>e.field?.dataset.field===i));if(!a)continue;let l=s.result.cloneNode(!0),o=l.querySelector("h3"),d=l.querySelector("p");const c=a.field?.querySelector("legend");o.textContent=c?c.textContent.replace("*","").trim():a.ui.label?.textContent.replace("*","").trim();const u=e.formatValueForSummary(n,a);u instanceof HTMLElement?d.replaceWith(u):d.textContent=u,t.append(l)}let n=a.config?.element?.querySelectorAll("[data-upload-field]");n&&n.forEach((e=>{let i=e.querySelector("h2")?.textContent??"Upload:",a=e.querySelectorAll(".item-grid.preview img"),r=s.result.cloneNode(!0);if(a){let e=s.result.cloneNode(!0),n=r.querySelector("h3"),l=r.querySelector("p");l?.remove(),n&&(n.textContent=i),a.forEach((t=>{t=t.cloneNode(!0),e.append(t)})),t.append(e)}})),s.result?.remove(),a.config.element.after(t),window.fade(a.config.element,!1)}})}initializeFields(e,t=null){const s={"[data-editor]":()=>this.checkForQuill(e,t),"div.quantity":()=>this.checkForQuantity(e),".repeater":()=>this.checkForRepeaters(e,t),".field.tag-list":()=>this.checkForTagLists(e),"[data-depends-on]":()=>this.checkForConditionalFields(e),"[data-limit]":()=>this.checkForCharacterLimits(e),"[data-uploader],[data-upload-field]":()=>this.checkForImageUploads(e,t),"nav.tabs":()=>this.checkForTabs(e,t),'[data-type="selector"]':()=>this.checkForSelectors(e)};for(const[t,i]of Object.entries(s))e.querySelector(t)&&i();Array.from(e.querySelectorAll(this.inputSelectors)).map((e=>{this.getItem(e,t?.id)}))}checkForQuill(e,t){if(!e.querySelector("[data-editor]"))return;t&&!Object.hasOwn(t,"hasQuill")&&(t.hasQuill=!0,this.forms.set(t.id,t)),this.quillInstances.has(t.id)||this.quillInstances.set(t.id,new Set);window.jvbQuill(e).forEach((e=>{this.quillInstances.get(t.id).add(e)}))}checkForQuantity(e){e.querySelector(this.selectors.number.number)&&e.querySelectorAll(this.selectors.number.number).forEach((t=>{let s={id:window.generateID("quant"),form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.number,t),element:t};t.dataset.numId=s.id,this.quantityFields.set(s.id,s),this.addQuantityListeners(t)}))}addQuantityListeners(e){e.addEventListener("click",this.quantityClick)}removeQuantityListeners(e){e.removeEventListener("click",this.quantityClick)}handleQuantityClick(e){let t=this.quantityFields.get(e.target.closest("[data-num-id]")?.dataset.numId);if(!t)return;let s=0;if(t.increase.contains(e.target)?s++:t.decrease.contains(e.target)&&s--,0===s)return;this.getField(e.target);let i=t.input.step;i=Math.max(i,1),e.ctrlKey&&e.shiftKey?i*=50:e.ctrlKey?i*=5:e.shiftKey&&(i*=10);let a=""===t.input.value?0:parseFloat(t.input.value);t.input.value=a+i*s,a=parseFloat(t.input.value),t.input.min&&a<t.input.min?(t.input.value=t.input.min,t.decrease.disabled=!0):t.input.max&&a>t.input.max?(t.input.value=t.input.max,t.increase.disabled=!0):(t.decrease.disabled&&(t.decrease.disabled=!1),t.increase.disabled&&(t.increase.disabled=!1))}checkForRepeaters(e){e.querySelector(this.selectors.repeater.repeater)&&e.querySelectorAll(this.selectors.repeater.repeater).forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("repeater"),ui:window.uiFromSelectors(this.selectors.repeater,t),form:e.dataset.formId,element:t,field:this.getField(t),sortable:!1};if(!s.ui.addButton)return;let i=t.querySelector("template");this.templates.define(i.className,{manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{let s=e.closest("[data-field]");window.prefixInput(t,`${e.dataset.fieldName}:${r}:`,s)}))}}),window.Sortable&&(s.sortable=new Sortable(t,{handle:this.selectors.repeater.header,animation:150,onEnd:()=>{this.reindexList(t)}})),t.dataset.repeaterId=s.id,this.addRepeaterListeners(t),this.repeaters.set(s.id,s)}))}addRepeaterListeners(e){e.addEventListener("click",this.repeaterClick)}removeRepeaterListeners(e){e.removeEventListener("click",this.repeaterClick)}handleRepeaterClick(e){e.target.matches(this.selectors.repeater.add)?this.addRepeaterRow(e.target.closest("[data-repeater-id]")):e.target.matches(this.selectors.repeater.remove)&&this.removeRepeaterRow(e.target)}addRepeaterRow(e){e.append(this.templates.create(e.dataset.repeaterId)),this.a11y.announce("Row added")}removeRepeaterRow(e){let t=e.closest("[data-repeater-id]");e.remove(),this.reindexList(t),this.a11y.announce("Row removed")}checkForTagLists(e){e.querySelectorAll(this.selectors.tagList.tagList)?.forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("tagList"),ui:window.uiFromSelectors(this.selectors.tagList,t),element:t,form:e.dataset.formId,format:t.dataset.tagFormat??"first_field"};if(!s.ui.input||!s.ui.add||!s.ui.items)return;t.dataset.tagListId=s.id;let i=t.querySelector("template");this.templates.define(i.className,{refs:{label:this.selectors.tagList.label},manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{let s=window.closest(".tag-item");window.prefixInput(t,`${e.dataset.fieldName}:${r}:`,s)})),t.label&&(t.label.textContent=a.label)}}),this.tagLists.set(s.id,s),this.addTagListListeners(t)}))}addTagListListeners(e){e.addEventListener("click",this.tagListClick),e.addEventListener("keypress",this.tagListInput,{passive:!0})}removeTagListListeners(e){e.removeEventListener("click",this.tagListClick),e.removeEventListener("keypress",this.tagListInput)}handleTagListClick(e){e.target.matches(this.selectors.tagList.add)?this.addTagListItem(e.target.closest("[data-tag-list-id]")):e.target.matches(this.selectors.tagList.remove)&&this.removeTagListItem(e.target.closest(this.selectors.tagList.remove))}addTagListItem(e){let t=this.tagLists.get(e.dataset.tagListId);if(!t)return;let s,i={},a=!1;for(let e of t.ui.inputs){this.validateField(e);const t=e.name.replace("new_",""),s=this.getFieldValue(e);s&&(a=!0),i[t]=s,["checkbox","radio"].includes(e.type)?e.checked=!1:e.value="",this.clearValidation(e)}if(!a)return this.a11y.announce("Please fill in at least one field"),void t.ui.inputs[0].focus();switch(t.format){case"first_field":s=Object.values(i)[0];break;case"all_fields":s=Object.values(i).join(", ");break;default:if(format.includes("{")){let e=t.format;for(const[t,s]of Object.entries(i))e=e.replace(`{${t}}`,s)}else s=i[t.format]??Object.values(i)[0]}let r=this.templates.create(e.dataset.tagListId,{label:s});const n=t.ui.items?.children?.length??0;r?.querySelectorAll("input[type=hidden]")?.forEach((e=>{const s=e.dataset.field;e.name=`${t.element.field}:${n}:${s}`,e.value=i[s]||""})),t.ui.items.append(r),t.ui.inputs[0]?.focus(),this.a11y.announce("Item added")}removeTagListItem(e){let t=e.closest("[data-tag-list-id]");e.remove(),this.reindexList(t),this.a11y.announce("Item removed")}handleTagListInput(e){let t=e.target,s=t.closest("[data-tag-list-id]");if(!s)return;let i=this.tagLists.get(s.dataset.tagListId);if(i&&"Enter"===e.key)if(t===i.ui.inputs[i.ui.inputs.length-1])e.preventDefault(),this.addTagListItem(t.closest("[data-tag-list-id]"));else{e.preventDefault();let s=i.ui.inputs.indexOf(t);i.ui.inputs[s+1].focus()}}checkForConditionalFields(e){e.querySelectorAll(this.selectors.dependsOn).forEach((t=>{const s=t.dataset.dependsOn,i=t.dataset.dependsValue,a=t.dataset.dependsOperatior??"==";if(!this.dependencies.has(s)){let e=document.querySelector(`[field="${s}"]`);e&&this.dependencies.set(s,{element:e,items:[]})}let r=this.dependencies.get(s);r.items.push({field:t,form:e.dataset.formId,requiredValue:i,operator:a}),this.dependencies.set(s,r),this.checkFieldDependency(r,s)}))}checkFieldDependency(e,t){const s=this.dependencies.get(t);if(!s)return;const i=this.getFieldCheckedValue(s.element),a=this.evaluateCondition(i,e.requiredValue,e.operator);this.toggleFieldVisibility(e.field,a)}evaluateCondition(e,t,s){const i=String(e||""),a=String(t||"");switch(s){case"==":default:return i===a;case"!=":return i!==a;case">":return parseFloat(i)>parseFloat(a);case"<":return parseFloat(i)<parseFloat(a);case">=":return parseFloat(i)>=parseFloat(a);case"<=":return parseFloat(i)<=parseFloat(a);case"contains":return i.includes(a);case"empty":return""===i;case"not_empty":return""!==i}}toggleFieldVisibility(e,t){const s=e.closest(".field, fieldset");s&&(s.hidden=!t,s.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}checkForCharacterLimits(e){e.querySelector(this.selectors.limits.hasLimit)&&(this.countUpdaters=this.updateCount.bind(this),e.querySelectorAll(`${this.selectors.limits.hasLimit}`).forEach((t=>{let s=window.generateID("limit");t.dataset.charLimitId=s;let i={element:t,form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.limits,t.closest(".field"))};i.ui.limit.textContent=t.dataset.limit,this.charLimits.set(s,i),this.addCharacterLimitListeners(t)})))}addCharacterLimitListeners(e){e.addEventListener("input",this.countUpdaters,{passive:!0})}removeCharacterLimitListeners(e){e.removeEventListener("input",this.countUpdaters,{passive:!0})}updateCount(e){let t=e.target,s=this.charLimits.get(t.dataset.charLimitId);if(!s)return;let i=t.value.length,a=t.dataset.limit;s.ui.current&&(s.ui.current.textContent=i,s.ui.current.classList.toggle("exceeded",i>=a)),i>a&&(t.value=t.value.slice(0,a))}checkForImageUploads(e,t){window.jvbUploads.scanFields(e,t.options.autoUpload,t.options.imageMeta)}checkForTabs(e,t){window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=window.jvbTabs.registerTab(e,{preCheck:(e,s)=>this.validateStep(e,t)}),t.ui.tabs=window.uiFromSelectors(this.selectors.tabs,e),t.ui.tabs.sections=Array.from(e.querySelectorAll(this.selectors.tabs.sections)),t.ui.tabs.inputs={},t.ui.tabs.sections.forEach((e=>{t.ui.tabs.inputs[e.dataset.tab]=Array.from(e.querySelectorAll(this.inputs))})),t.ui.tabs.buttons=Array.from(e.querySelectorAll(this.selectors.tabs.buttons)),t.unsubscribeTabs=window.jvbTabs.subscribe(((e,s)=>{if("tab-switched"===e&&t.ui.tabs.progress){const e=t.ui.tabs.sections.filter((e=>e.dataset.tab===s.current))[0]??!1;if(!e)return;const i=e.dataset.step,a=t.ui.sections.length;window.showProgress(t.ui.tabs.progress,i,a)}})),this.forms.set(t.id,t))}validateStep(e,t){const s=e.closest("[data-form-id]")?.dataset.formId;if(!s)return!0;if(!this.forms.get(s))return!0;return Array.from(this.inputs.values()).filter((t=>t&&t.form===s&&t.section===e.dataset.tab&&!t.element.closest("[hidden]"))).every((e=>!0===this.validateField(e.element)))}checkForSelectors(e){window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}reindexList(e){const t=e.dataset.field||e.dataset.repeaterId||e.dataset.tagListId;Array.from(e.children).forEach(((e,s)=>{e.dataset.index=`${s}`;e.querySelectorAll("input, select, textarea").forEach((i=>{if("file"===i.type)return;i.dataset.field||i.name.split(":").pop();window.prefixInput(i,`${t}:${s}:`,e)}))}))}clearValidation(e){let t=this.getField(e);if(!t)return;let s=this.getItem(e);s&&(t.classList.remove("has-error","has-success"),s.ui.success&&(s.ui.success.hidden=!0),s.ui.error&&(s.ui.error.hidden=!0),s.ui.message&&(s.ui.message.hidden=!0,s.ui.message.textContent=""))}showError(e,t="Invalid field"){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-success"),s.classList.add("has-error"),i.ui.success&&(i.ui.success.hidden=!0),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=!1,i.ui.message.textContent=t))}showSuccess(e,t=""){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-error"),s.classList.add("has-success"),i.ui.success&&(i.ui.success.hidden=!1),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=""===t,i.ui.message.textContent=t))}handleFormSuccess(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error").forEach((e=>e.classList.remove("field-error"))),e.classList.add("form-success"),t.message){const s=document.createElement("div");s.className="form-success-message success-message",s.textContent=t.message,e.insertBefore(s,e.firstChild);const i=window.getIcon?.("check-circle");i&&(i.classList.add("success-icon"),s.prepend(i))}if(t.title||t.description){const s=document.createElement("div");if(s.className="success-box",t.title){const e=document.createElement("h3");e.textContent=t.title,s.appendChild(e)}if(t.description){(Array.isArray(t.description)?t.description:[t.description]).forEach((e=>{const t=document.createElement("p");t.textContent=e,s.appendChild(t)}))}e.insertBefore(s,e.firstChild)}if(e.dataset.formId){this.store.delete(e.dataset.formId).catch((e=>{console.warn("Failed to clear form cache:",e)}));const t=this.forms.get(e.dataset.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}window.jvbA11y&&window.jvbA11y.announce(t.message||"Form submitted successfully")}handleFormError(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error, .has-error").forEach((e=>{e.classList.remove("field-error","has-error")})),e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)})),t.field){const s=e.querySelector(`[data-field="${t.field}"]`);if(s){this.showError(s,t.message),this.touchedFields.add(t.field),s.scrollIntoView({behavior:"smooth",block:"center"});const e=s.querySelector("input, textarea, select");e&&e.focus()}}else{const s=document.createElement("div");s.className="form-error error-message",s.textContent=t.message;const i=window.getIcon?.("close-circle");i&&(i.classList.add("error-icon"),s.prepend(i)),e.insertBefore(s,e.firstChild),e.scrollIntoView({behavior:"smooth",block:"start"})}if(window.jvbA11y){const e=t.field?`Error in ${t.field}: ${t.message}`:`Form error: ${t.message}`;window.jvbA11y.announce(e)}e.dispatchEvent(new CustomEvent("jvb-form-error",{detail:t}))}showFormStatus(e,t,s=""){let i=this.forms.get(e);i&&i.options.showStatus&&i.ui?.status?.status&&i.status!==t&&(i.status=t,i.ui.status.status.hidden=!1,i.ui.status.status.classList.toggle("loading",["uploading","saving"].includes(t)),i.ui.status.message.textContent=""===s?this.getDefaultMessage(t):s,i.ui.status.icon.className="icon icon-"+this.getDefaultIcon(t),setTimeout((()=>i.ui.status.status.hidden=!0),"submitted"===t?3e3:1e4))}getDefaultMessage(e){return{saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",restored:"Welcome back! We've restored your previous entry.",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"}[e]??e}getDefaultIcon(e){return{autosaved:"check-circle",submitted:"check-circle",restored:"history",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[e]??""}showSummary(e){let t=this.templates.create("formSummary",e);e.config.element.after(t),window.fade(e.config.element,!1)}getForm(e){let t=e.closest("[data-form-id]").dataset.formId;if(!t)return!1;let s=this.forms.get(t);return s||!1}getField(e){return e.closest("[data-field]")}getFieldType(e){let t=this.getField(e);if(t)return t.dataset.fieldType}getFieldValue(e){let t=this.getFieldType(e),s=this.getItem(e),i=s.field?.dataset.field??!1;if(!i)return!1;switch(t){case"repeater":return this.getRepeaterValue(e,s);case"tag-list":return this.getTagListValue(e,s);case"group":break;case"location":return this.getLocationValue(e,s);case"selector":case"upload":return this.getHiddenInputValue(e,s,i);case"true-false":return"1"===e.value||"on"===e.value||"true"===e.value;case"checkbox":return e.name.endsWith("[]")?this.getCheckboxGroupValue(e,s):e.checked?e.value:"";default:return e.value}}getCheckboxGroupValue(e,t){return t.checkboxGroup||(t.checkboxGroup=t.field?.querySelectorAll(`input[type="checkbox"][name="${e.name}"]`),this.saveItem(t)),Array.from(t.checkboxGroup).filter((e=>e.checked)).map((e=>e.value))}getFieldCheckedValue(e){if("checkbox"===e.type){return"true-false"===this.getFieldType(e)?e.checked:e.checked?e.value:""}if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`),s=Array.from(t).find((e=>e.checked));return s?s.value:""}return this.getFieldValue(e)}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getRepeaterValue(e,t){t.container||(t.container=t.field?.querySelector(".repeater-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t={};e.querySelectorAll("[data-field]").forEach((e=>{t[e.dataset.field]=this.getFieldValue(e)})),s.push(t)})),s}getTagListValue(e,t){t.container||(t.container=t.field?.querySelector(".tag-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t=e.querySelectorAll('input[type="hidden"]'),i={};t.forEach((e=>{i[e.dataset.field]=e.value})),s.push(i)})),s}getLocationValue(e,t){t.values||(t.values=Array.from(t.field?.querySelectorAll("[data-location-field]")),this.saveItem(t));let s={};return t.values.forEach((e=>{s[e.dataset.locationField]=e.value})),s}getHiddenInputValue(e,t,s){return t.value||(t.value=t.field?.querySelector(`input[type=hidden][name="${s}"]`),this.saveItem(t)),t.value.value}formatValueForSummary(e,t){const s=this.getFieldType(t.element);if(this.isEmptyValue(e))return"";switch(s){case"repeater":return this.formatRepeaterForSummary(e,t);case"tag-list":return this.formatTagListForSummary(e,t);case"location":return this.formatLocationForSummary(e);case"true-false":return e?"Yes":"No";case"checkbox":return Array.isArray(e)?this.formatCheckboxGroupForSummary(e,t):this.getDisplayLabel(t,e);case"selector":case"upload":return this.formatHiddenFieldForSummary(e,t,s);default:return"string"==typeof e?this.getDisplayLabel(t,e):"string"==typeof e&&e.includes("\n")?this.convertLineBreaks(e):e}}formatCheckboxGroupForSummary(e,t){return e.map((e=>this.getDisplayLabel(t,e))).join(", ")}convertLineBreaks(e){const t=document.createElement("span");return t.innerHTML=e.split("\n").join("<br>"),t}formatRepeaterForSummary(e,t){const s=document.createElement("div");return s.className="summary-repeater",e.forEach(((e,i)=>{const a=document.createElement("div");a.className="summary-repeater-row";const r=document.createElement("strong");r.textContent=`Entry ${i+1}:`,a.appendChild(r);const n=document.createElement("ul");n.className="summary-repeater-fields";for(const[s,i]of Object.entries(e)){if(this.isEmptyValue(i))continue;const e=document.createElement("li"),a=t.field?.querySelector(`[data-field="${s}"]`),r=a?.closest(".field")?.querySelector("label")?.textContent.replace("*","").trim()||s;e.innerHTML=`<span class="field-label">${r}:</span> <span class="field-value">${i}</span>`,n.appendChild(e)}a.appendChild(n),s.appendChild(a)})),s}formatTagListForSummary(e,t){const s=document.createElement("div");s.className="summary-taglist";const i=document.createElement("ul");return i.className="summary-tags",e.forEach((e=>{const t=document.createElement("li");t.className="summary-tag";const s=Object.values(e).find((e=>!this.isEmptyValue(e)))||"",a=Object.entries(e).filter((([e,t])=>!this.isEmptyValue(t)));a.length>1?t.textContent=a.map((([e,t])=>t)).join(", "):t.textContent=s,i.appendChild(t)})),s.appendChild(i),s}formatLocationForSummary(e){const t=[];return e.street&&t.push(e.street),e.city&&t.push(e.city),e.province&&t.push(e.province),e.postal_code&&t.push(e.postal_code),e.country&&t.push(e.country),t.length>0?t.join(", "):e.address||""}formatHiddenFieldForSummary(e,t,s){if("upload"===s){const s=t.field?.querySelector("[data-upload-field]");if(s){const e=s.querySelectorAll(".item-grid.preview img");if(e.length>0){const t=document.createElement("div");return t.className="summary-uploads",e.forEach((e=>{const s=e.cloneNode(!0);s.style.maxWidth="100px",s.style.maxHeight="100px",t.appendChild(s)})),t}}return`${e.split(",").length} file(s) uploaded`}return e}getDisplayLabel(e,t){if(!e.element)return t;const s=e.element.type;if("radio"===s){const s=e.field.querySelectorAll(`input[type="radio"][name="${e.element.name}"]`),i=Array.from(s).find((e=>e.value===t));if(i){const t=i.closest("label")||e.field.querySelector(`label[for="${i.id}"]`);if(t)return t.textContent.replace("*","").trim()}}if("checkbox"===s&&"true-false"!==this.getFieldType(e.element)){const s=e.field.querySelector(`input[type="checkbox"][value="${t}"]`);if(s){const t=s.closest("label")||e.field.querySelector(`label[for="${s.id}"]`);if(t){const e=t.querySelector("span");return e?e.textContent.trim():t.textContent.replace("*","").trim()}}}return t}getItem(e,t=null){const s=Object.hasOwn(e.dataset,"ref");let i=s?e.dataset.ref:window.generateID("input");if(s||(e.dataset.ref=i),!this.inputs.has(i)){t||(t=e.closest("[data-form-id]")?.dataset.formId??!1);let s=this.getField(e);this.inputs.set(i,{id:i,element:e,form:t,field:s,section:e.closest("[data-tab]")?.dataset.tab??!1,ui:window.uiFromSelectors(this.selectors.fields,s)})}return this.inputs.get(i)}saveItem(e){this.inputs.set(e.id,e)}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("HandleSelection subscriber error:",e)}}))}destroy(){this.forms.size>0&&(Array.from(this.forms.values()).forEach((e=>{this.removeFormListeners(e)})),this.forms.clear()),this.repeaters.size>0&&(Array.from(this.repeaters.values()).forEach((e=>{this.removeRepeaterListeners(e.element),e.sortable?.destroy()})),this.repeaters.clear()),this.quantityFields.size>0&&(Array.from(this.quantityFields.values()).forEach((e=>{this.removeQuantityListeners(e.element)})),this.quantityFields.clear()),this.tagLists.size>0&&(Array.from(this.tagLists.values()).forEach((e=>{this.removeTagListListeners(e.element)})),this.tagLists.clear()),this.charLimits.size>0&&Array.from(this.charLimits.values()).forEach((e=>{e.removeEventListener("input",this.countUpdaters)})),this.inputs.clear(),this.forms.clear(),this.charLimits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbForm=new e)}))}))})(); |
| | | (()=>{class e{constructor(){this.a11y=window.jvbA11y,this.error=window.jvbError,this.queue=window.jvbQueue,this.populate=window.jvbPopulate,this.changes=new Map,this.forms=new Map,this.inputs=new Map,this.repeaters=new Map,this.tagLists=new Map,this.charLimits=new Map,this.quantityFields=new Map,this.quillInstances=new Map,this.dependencies=new Map,this.subscribers=new Set,this.isRestoring=!1,this.hasListeners=!1,this.summaryTemplate=!1,this.init()}init(){this.templates=window.jvbTemplates,this.defineSummaryTemplate(),this.initElements(),this.initListeners(),this.initStore(),this.initValidators()}initElements(){this.inputSelectors="input, textarea, select",this.selectors={tabs:{nav:"nav.tabs",sections:".tab.content",progress:{progress:".progress",fill:".progress .fill",details:".progress .details",icon:".progress .icon"},buttons:"nav.tabs button"},dependsOn:"[data-depends-on]",forms:{status:{status:".fstatus",message:".fstatus .message",icon:".fstatus .icon",actions:".fstatus .actions"}},inputs:this.inputSelectors,fields:{field:".field",label:"label",success:".success",error:".success",message:".validation-message"},repeater:{repeater:".repeater",header:".repeater-row-header",remove:".remove-row",add:".add-repeater-row",template:"template",items:".repeater-items",inputs:this.inputSelectors},tagList:{tagList:".field.tag-list",input:".row",add:".add-tag",remove:".remove-tag",label:".tag-label",items:".tag-items",inputs:this.inputSelectors,value:'input[type="hidden"]'},tag:{label:".tag-label"},number:{number:".field div.quantity",increase:"button.increase",decrease:"button.decrease",input:'input[type="number"]'},limits:{hasLimit:"[data-limit]",limit:".limit",current:".current"}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.blurHandler=this.handleBlur.bind(this),this.inputHandler=this.handleInput.bind(this),this.submitHandler=this.handleSubmit.bind(this),this.quantityClick=this.handleQuantityClick.bind(this),this.repeaterClick=this.handleRepeaterClick.bind(this),this.tagListClick=this.handleTagListClick.bind(this),this.tagListInput=this.handleTagListInput.bind(this)}addFormListeners(e){e.addEventListener("click",this.clickHandler),e.addEventListener("change",this.changeHandler),e.addEventListener("input",this.inputHandler),e.addEventListener("blur",this.blurHandler),e.addEventListener("submit",this.submitHandler)}removeFormListeners(e){e.removeEventListener("click",this.clickHandler),e.removeEventListener("change",this.changeHandler),e.removeEventListener("input",this.inputHandler),e.removeEventListener("blur",this.blurHandler),e.removeEventListener("submit",this.submitHandler)}initStore(){const e=window.jvbStore.register("forms",{storeName:"forms",keyPath:"id",indexes:[{name:"src",keyPath:"src"},{name:"timestamp",keyPath:"timestamp"},{name:"formType",keyPath:"type"}],TTL:1008e4});this.store=e.forms,this.store.subscribe(((e,t)=>{if("data-ready"===e){let e=this.store.getFiltered().filter((e=>e.src===window.location.pathname));for(let t of e)this.showPendingNotification(t.id,t.changes)}else"operation-status"===e&&"completed"===t.status&&t.config&&this.store.delete(t.config.id)}))}showPendingNotification(e,t){let s=this.forms.get(e);if(!s)return;let i=s.element;if(!i)return void console.warn(`Form element not found for: ${e}`);const a=document.createElement("div");a.className="pendingChanges",a.innerHTML=`\n\t\t\t<p>We noticed unsaved changes from last time. Would you like to restore them?</p>\n <button class="restore" type="button" data-form-id="${e}">Restore</button>\n <button class="discard" type="button" data-form-id="${e}">Discard</button>`,i.insertBefore(a,s.ui.status.status),a.querySelector(".restore").addEventListener("click",(async()=>{this.isRestoring=!0;let e={fields:t};this.populate.populate(i,e),this.a11y.announce("Previous changes restored"),this.isRestoring=!1,a.remove()})),a.querySelector(".discard").addEventListener("click",(async()=>{await this.store.delete(e),this.a11y.announce("Previous changes discarded"),a.remove()}))}initValidators(){this.validators={email:{pattern:/^[^\s@]+@[^\s@]+\.[^\s@]+$/,message:"Please enter a valid email address"},url:{pattern:/^https?:\/\/.+\..+/,message:"Please enter a valid URL starting with https://"},phone:{pattern:/^[\d\s\-+().]+$/,message:"Please enter a valid phone number"},number:{test:(e,t)=>{const s=parseFloat(e);if(isNaN(s))return"Please enter a valid number";const i=t.dataset.min,a=t.dataset.max;return void 0!==i&&s<parseFloat(i)?`Value must be at least ${i}`:!(void 0!==a&&s>parseFloat(a))||`Value must be at most ${a}`}},text:{test:(e,t)=>{const s=t.dataset.minlength,i=t.dataset.maxlength;return s&&e.length<parseInt(s)?`Must be at least ${s} characters`:!(i&&e.length>parseInt(i))||`Must be no more than ${i} characters`}}}}validateField(e){const t=this.performValidation(e);return this.updateValidationUI(e,t),t.isValid}performValidation(e){const t=e.closest(".field"),s=this.getFieldCheckedValue(e);if(!s&&!e.required)return{isValid:!0,message:""};if(e.required)if("checkbox"===e.type){if(!e.checked)return{isValid:!1,message:"This field is required"}}else if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`);if(!Array.from(t).some((e=>e.checked)))return{isValid:!1,message:"Please select an option"}}else if(!s)return{isValid:!1,message:"This field is required"};if(e.checkValidity&&!e.checkValidity())return{isValid:!1,message:e.validationMessage};if(s&&Object.hasOwn(t.dataset,"pattern")){if(!new RegExp(t.dataset.pattern).test(s))return{isValid:!1,message:t.dataset.validationMessage||"Invalid format"}}if(Object.hasOwn(t.dataset,"validate")||e.type){const i=this.validators[t.dataset.validate||e.type];if(i&&i.pattern&&!i.pattern.test(s))return{isValid:!1,message:i.message};if(i&&i.test){const e=i.test(s,t);if(!0!==e)return{isValid:!1,message:e}}}return{isValid:!0,message:""}}updateValidationUI(e,t){t.isValid?this.showSuccess(e,t.message):this.showError(e,t.message)}handleClick(e){let t=this.getForm(e.target);if(!t)return;const s=window.targetCheck(e,"[data-action]");if(s){switch(s.dataset.action){case"clear-form":this.store.delete(t.id),t.element.reset(),t.ui.status.status.hidden=!0,this.a11y.announce("Form cleared, starting fresh");break;case"dismiss-restore":t.ui.status.status.hidden=!0}}}handleChange(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getField(e.target);if(this.dependencies.has(t.dataset.field)){this.dependencies.get(t.dataset.field).items.forEach((e=>{this.checkFieldDependency(e,t.dataset.field)}))}if(Object.hasOwn(t.dataset,"repeater-id")||Object.hasOwn(t.dataset,"tag-list-id"))return void this.updateCollectionField(t);let s=this.getForm(e.target);this.updateItem(t.dataset.field,this.getFieldValue(e.target),s)}handleBlur(e){if(e.target.closest("[data-ignore]")||this.isRestoring)return;let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target).dataset.field;window.debouncer.cancel(`form:${t.id}:validate:${s}`),this.validateField(e.target),this.updateItem(s,this.getFieldValue(e.target),t)}handleInput(e){let t=this.getForm(e.target);if(!t)return;let s=this.getField(e.target);if(!s)return;const i=e.target,a=s.dataset.field;this.showFormStatus(t.id,"pending"),window.debouncer.schedule(`form:${t.id}:validate:${a}`,(()=>this.validateField(i)),500)}async handleSubmit(e){let t=this.getForm(e.target);if(t){if(this.subscribers.size>0)if(e.preventDefault(),t.options.cache){this.cancelBackup(),await this.backup();const e=await this.store.get(t.id);this.notify("form-submit",{config:t,data:e.changes})}else this.notify("form-submit",{config:t,data:this.changes.get(t.id)?.changes??{}});if(t.options.showSummary){const e=await this.store.get(t.id);this.showSummary({config:t,changes:e?.changes})}}}updateItem(e,t,s){this.changes.has(s.id)||this.changes.set(s.id,{id:s.id,timestamp:Date.now(),src:window.location.pathname,changes:{}});let i=this.changes.get(s.id);i.changes[e]=t,this.changes.set(s.id,i),s.options.cache&&this.scheduleBackup()}scheduleBackup(){window.debouncer.schedule("form_changes",(async()=>{this.changes.size>0&&await this.backup()}),2e3)}cancelBackup(){window.debouncer.cancel("form_changes")}async backup(){const e=new Map;for(let[t,s]of this.changes.entries()){const i=await this.store.get(t);i?e.set(t,{...i,...s,changes:{...i.changes,...s.changes},timestamp:Date.now()}):e.set(t,s)}await this.store.saveMany(e);for(let e of this.changes.keys())this.showFormStatus(e,"autosaved");this.changes.clear()}saveCache(e){if(!this.changes.has(e))return;let t=this.changes.get(e);0!==t.size&&(this.store.save(t).then((()=>{})),this.changes.delete(e))}registerForm(e,t){if(Object.hasOwn(e.dataset,"formId")&&this.forms.has(e.dataset.formId))return;Object.hasOwn(e.dataset,"formId")||(e.dataset.formId=window.generateID("form_"));const s=e.dataset.formId;this.addFormListeners(e);const i={element:e,id:s,status:"",options:{autoUpload:t.autoUpload??!1,imageMeta:t.imageMeta??!0,delay:t.delay??1500,endpoint:t.save??e.dataset.save??"",showStatus:t.showStatus??!0,showSummary:t.showSummary??!1,cache:t.cache??!0,ignore:t.ignore??[]},ui:window.uiFromSelectors(this.selectors.forms,e)};return this.initializeFields(e,i),this.forms.set(s,i),i}clearForm(e){const t=this.forms.get(e);if(!t)return;t.unsubscribeTabs&&t.unsubscribeTabs(),t.tabs&&window.jvbTabs.removeTab(t.element),t.cache&&this.changes.has(e)&&this.saveCache(e);for(let[t,s]of this.inputs.entries())s.form===e&&this.inputs.delete(t);if(this.dependencies.forEach(((t,s)=>{t.items=t.items.filter((t=>t.form!==e)),0===t.items.length&&this.dependencies.delete(s)})),Object.hasOwn(t,"hasQuill")&&this.quillInstances.has(e)){this.quillInstances.get(e).forEach((e=>{e.disable(),e.off("text-change"),e.off("selection-change");const t=e.container.parentElement,s=t?.querySelector(".ql-toolbar");if(s&&s.remove(),e.setText(""),t&&t.classList.contains("editor-container")){const e=t.nextElementSibling;"TEXTAREA"===e?.tagName&&(e.style.display=""),t.remove()}})),this.quillInstances.delete(e)}let s={repeater:this.repeaters,tagList:this.tagLists,charLimit:this.charLimits,quantity:this.quantityFields};for(let[t,i]of Object.entries(s)){if(0===i.size)continue;let s=Array.from(i.values()).filter((t=>t.form===e));s.length>0&&s.forEach((e=>{switch(t){case"repeater":this.removeRepeaterListeners(e.element);break;case"tagList":this.removeTagListListeners(e.element);break;case"charLimit":this.removeCharacterLimitListeners(e.element);break;case"quantity":this.removeQuantityListeners(e.element)}i.has(e.id)&&i.delete(e.id)}))}this.removeFormListeners(t.element),this.forms.delete(e),window.debouncer.cancel("form_changes")}defineSummaryTemplate(){this.summaryTemplate=!0;let e=this;this.templates.define("formSummary",{refs:{result:".result",h3:"h3",p:"p"},setup({el:t,refs:s,manyRefs:i,data:a}){const r=["sendAll",...a.config.options.ignore??[]];for(let[i,n]of Object.entries(a.changes)){if(r.includes(i)||e.isEmptyValue(n))continue;let a=Array.from(e.inputs.values()).find((e=>e.field?.dataset.field===i));if(!a)continue;let l=s.result.cloneNode(!0),o=l.querySelector("h3"),d=l.querySelector("p");const c=a.field?.querySelector("legend");o.textContent=c?c.textContent.replace("*","").trim():a.ui.label?.textContent.replace("*","").trim();const u=e.formatValueForSummary(n,a);u instanceof HTMLElement?d.replaceWith(u):d.textContent=u,t.append(l)}let n=a.config?.element?.querySelectorAll("[data-upload-field]");n&&n.forEach((e=>{let i=e.querySelector("h2")?.textContent??"Upload:",a=e.querySelectorAll(".item-grid.preview img"),r=s.result.cloneNode(!0);if(a){let e=s.result.cloneNode(!0),n=r.querySelector("h3"),l=r.querySelector("p");l?.remove(),n&&(n.textContent=i),a.forEach((t=>{t=t.cloneNode(!0),e.append(t)})),t.append(e)}})),s.result?.remove(),a.config.element.after(t),window.fade(a.config.element,!1)}})}initializeFields(e,t=null){const s={"[data-editor]":()=>this.checkForQuill(e,t),"div.quantity":()=>this.checkForQuantity(e),".repeater":()=>this.checkForRepeaters(e,t),".field.tag-list":()=>this.checkForTagLists(e),"[data-depends-on]":()=>this.checkForConditionalFields(e),"[data-limit]":()=>this.checkForCharacterLimits(e),"[data-uploader],[data-upload-field]":()=>this.checkForImageUploads(e,t),"nav.tabs":()=>this.checkForTabs(e,t),'[data-type="selector"]':()=>this.checkForSelectors(e)};for(const[t,i]of Object.entries(s))e.querySelector(t)&&i();Array.from(e.querySelectorAll(this.inputSelectors)).map((e=>{this.getItem(e,t?.id)}))}checkForQuill(e,t){if(!e.querySelector("[data-editor]"))return;t&&!Object.hasOwn(t,"hasQuill")&&(t.hasQuill=!0,this.forms.set(t.id,t)),this.quillInstances.has(t.id)||this.quillInstances.set(t.id,new Set);window.jvbQuill(e).forEach((e=>{this.quillInstances.get(t.id).add(e)}))}checkForQuantity(e){e.querySelector(this.selectors.number.number)&&e.querySelectorAll(this.selectors.number.number).forEach((t=>{let s={id:window.generateID("quant"),form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.number,t),element:t};t.dataset.numId=s.id,this.quantityFields.set(s.id,s),this.addQuantityListeners(t)}))}addQuantityListeners(e){e.addEventListener("click",this.quantityClick)}removeQuantityListeners(e){e.removeEventListener("click",this.quantityClick)}handleQuantityClick(e){let t=this.quantityFields.get(e.target.closest("[data-num-id]")?.dataset.numId);if(!t)return;let s=0;if(t.increase.contains(e.target)?s++:t.decrease.contains(e.target)&&s--,0===s)return;this.getField(e.target);let i=t.input.step;i=Math.max(i,1),e.ctrlKey&&e.shiftKey?i*=50:e.ctrlKey?i*=5:e.shiftKey&&(i*=10);let a=""===t.input.value?0:parseFloat(t.input.value);t.input.value=a+i*s,a=parseFloat(t.input.value),t.input.min&&a<t.input.min?(t.input.value=t.input.min,t.decrease.disabled=!0):t.input.max&&a>t.input.max?(t.input.value=t.input.max,t.increase.disabled=!0):(t.decrease.disabled&&(t.decrease.disabled=!1),t.increase.disabled&&(t.increase.disabled=!1))}checkForRepeaters(e){e.querySelector(this.selectors.repeater.repeater)&&e.querySelectorAll(this.selectors.repeater.repeater).forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("repeater"),ui:window.uiFromSelectors(this.selectors.repeater,t),form:e.dataset.formId,element:t,field:this.getField(t),sortable:!1};if(!s.ui.add)return;let i=t.querySelector("template");this.templates.define(i.className,{manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{window.prefixInput(t,`${a.repeater.dataset.fieldName}:${r}:`,e)}))}}),window.Sortable&&(s.sortable=new Sortable(t,{handle:this.selectors.repeater.header,animation:150,onEnd:()=>{this.reindexList(t)}})),t.dataset.repeaterId=s.id,this.addRepeaterListeners(t),this.repeaters.set(s.id,s)}))}addRepeaterListeners(e){e.addEventListener("click",this.repeaterClick)}removeRepeaterListeners(e){e.removeEventListener("click",this.repeaterClick)}handleRepeaterClick(e){e.target.matches(this.selectors.repeater.add)?(console.log("Add Repeater Row"),this.addRepeaterRow(e.target.closest("[data-repeater-id]"))):e.target.matches(this.selectors.repeater.remove)&&(console.log("Remove Repeater Row"),this.removeRepeaterRow(e.target.closest("[data-index]")))}addRepeaterRow(e){let t={};t.repeater=e,e.append(this.templates.create(e.dataset.repeaterId,t)),this.initializeFields(e,this.getField(e).config??{}),this.a11y.announce("Row added")}removeRepeaterRow(e){let t=e.closest("[data-repeater-id]");e.remove(),this.reindexList(t),this.a11y.announce("Row removed")}checkForTagLists(e){e.querySelectorAll(this.selectors.tagList.tagList)?.forEach((t=>{let s={id:t.querySelector("template").className??window.generateID("tagList"),ui:window.uiFromSelectors(this.selectors.tagList,t),element:t,form:e.dataset.formId,format:t.dataset.tagFormat??"first_field"};if(!s.ui.input||!s.ui.add||!s.ui.items)return;t.dataset.tagListId=s.id;let i=t.querySelector("template");this.templates.define(i.className,{refs:{label:this.selectors.tagList.label},manyRefs:{inputs:this.inputSelectors},setup({el:e,refs:t,manyRefs:i,data:a}){let r=s.ui.items?.children?.length??0;e.dataset.index=r,i.inputs?.forEach((t=>{let s=window.closest(".tag-item");window.prefixInput(t,`${e.dataset.fieldName}:${r}:`,s)})),t.label&&(t.label.textContent=a.label)}}),this.tagLists.set(s.id,s),this.addTagListListeners(t)}))}addTagListListeners(e){e.addEventListener("click",this.tagListClick),e.addEventListener("keypress",this.tagListInput,{passive:!0})}removeTagListListeners(e){e.removeEventListener("click",this.tagListClick),e.removeEventListener("keypress",this.tagListInput)}handleTagListClick(e){e.target.matches(this.selectors.tagList.add)?this.addTagListItem(e.target.closest("[data-tag-list-id]")):e.target.matches(this.selectors.tagList.remove)&&this.removeTagListItem(e.target.closest(this.selectors.tagList.remove))}addTagListItem(e){let t=this.tagLists.get(e.dataset.tagListId);if(!t)return;let s,i={},a=!1;for(let e of t.ui.inputs){this.validateField(e);const t=e.name.replace("new_",""),s=this.getFieldValue(e);s&&(a=!0),i[t]=s,["checkbox","radio"].includes(e.type)?e.checked=!1:e.value="",this.clearValidation(e)}if(!a)return this.a11y.announce("Please fill in at least one field"),void t.ui.inputs[0].focus();switch(t.format){case"first_field":s=Object.values(i)[0];break;case"all_fields":s=Object.values(i).join(", ");break;default:if(format.includes("{")){let e=t.format;for(const[t,s]of Object.entries(i))e=e.replace(`{${t}}`,s)}else s=i[t.format]??Object.values(i)[0]}let r=this.templates.create(e.dataset.tagListId,{label:s});const n=t.ui.items?.children?.length??0;r?.querySelectorAll("input[type=hidden]")?.forEach((e=>{const s=e.dataset.field;e.name=`${t.element.field}:${n}:${s}`,e.value=i[s]||""})),t.ui.items.append(r),t.ui.inputs[0]?.focus(),this.updateCollectionField(e),this.a11y.announce("Item added")}removeTagListItem(e){let t=e.closest("[data-tag-list-id]");e.remove(),this.reindexList(t),this.a11y.announce("Item removed")}handleTagListInput(e){let t=e.target,s=t.closest("[data-tag-list-id]");if(!s)return;let i=this.tagLists.get(s.dataset.tagListId);if(i&&"Enter"===e.key)if(t===i.ui.inputs[i.ui.inputs.length-1])e.preventDefault(),this.addTagListItem(t.closest("[data-tag-list-id]"));else{e.preventDefault();let s=i.ui.inputs.indexOf(t);i.ui.inputs[s+1].focus()}}checkForConditionalFields(e){e.querySelectorAll(this.selectors.dependsOn).forEach((t=>{const s=t.dataset.dependsOn,i=t.dataset.dependsValue,a=t.dataset.dependsOperatior??"==";if(!this.dependencies.has(s)){let e=document.querySelector(`[field="${s}"]`);e&&this.dependencies.set(s,{element:e,items:[]})}let r=this.dependencies.get(s);r.items.push({field:t,form:e.dataset.formId,requiredValue:i,operator:a}),this.dependencies.set(s,r),this.checkFieldDependency(r,s)}))}checkFieldDependency(e,t){const s=this.dependencies.get(t);if(!s)return;const i=this.getFieldCheckedValue(s.element),a=this.evaluateCondition(i,e.requiredValue,e.operator);this.toggleFieldVisibility(e.field,a)}evaluateCondition(e,t,s){const i=String(e||""),a=String(t||"");switch(s){case"==":default:return i===a;case"!=":return i!==a;case">":return parseFloat(i)>parseFloat(a);case"<":return parseFloat(i)<parseFloat(a);case">=":return parseFloat(i)>=parseFloat(a);case"<=":return parseFloat(i)<=parseFloat(a);case"contains":return i.includes(a);case"empty":return""===i;case"not_empty":return""!==i}}toggleFieldVisibility(e,t){const s=e.closest(".field, fieldset");s&&(s.hidden=!t,s.querySelectorAll("input, select, textarea").forEach((e=>{e.disabled=!t,!t&&e.hasAttribute("required")?(e.dataset.wasRequired="true",e.removeAttribute("required")):t&&"true"===e.dataset.wasRequired&&(e.setAttribute("required",""),delete e.dataset.wasRequired)})))}checkForCharacterLimits(e){e.querySelector(this.selectors.limits.hasLimit)&&(this.countUpdaters=this.updateCount.bind(this),e.querySelectorAll(`${this.selectors.limits.hasLimit}`).forEach((t=>{let s=window.generateID("limit");t.dataset.charLimitId=s;let i={element:t,form:e.dataset.formId,ui:window.uiFromSelectors(this.selectors.limits,t.closest(".field"))};i.ui.limit.textContent=t.dataset.limit,this.charLimits.set(s,i),this.addCharacterLimitListeners(t)})))}addCharacterLimitListeners(e){e.addEventListener("input",this.countUpdaters,{passive:!0})}removeCharacterLimitListeners(e){e.removeEventListener("input",this.countUpdaters,{passive:!0})}updateCount(e){let t=e.target,s=this.charLimits.get(t.dataset.charLimitId);if(!s)return;let i=t.value.length,a=t.dataset.limit;s.ui.current&&(s.ui.current.textContent=i,s.ui.current.classList.toggle("exceeded",i>=a)),i>a&&(t.value=t.value.slice(0,a))}checkForImageUploads(e,t){window.jvbUploads.scanFields(e,t.options.autoUpload,t.options.imageMeta)}checkForTabs(e,t){window.jvbTabs&&e.querySelector("nav.tabs")&&(t.tabs=window.jvbTabs.registerTab(e,{preCheck:(e,s)=>this.validateStep(e,t)}),t.ui.tabs=window.uiFromSelectors(this.selectors.tabs,e),t.ui.tabs.sections=Array.from(e.querySelectorAll(this.selectors.tabs.sections)),t.ui.tabs.inputs={},t.ui.tabs.sections.forEach((e=>{t.ui.tabs.inputs[e.dataset.tab]=Array.from(e.querySelectorAll(this.inputs))})),t.ui.tabs.buttons=Array.from(e.querySelectorAll(this.selectors.tabs.buttons)),t.unsubscribeTabs=window.jvbTabs.subscribe(((e,s)=>{if("tab-switched"===e&&t.ui.tabs.progress){const e=t.ui.tabs.sections.filter((e=>e.dataset.tab===s.current))[0]??!1;if(!e)return;const i=e.dataset.step,a=t.ui.sections.length;window.showProgress(t.ui.tabs.progress,i,a)}})),this.forms.set(t.id,t))}validateStep(e,t){const s=e.closest("[data-form-id]")?.dataset.formId;if(!s)return!0;if(!this.forms.get(s))return!0;return Array.from(this.inputs.values()).filter((t=>t&&t.form===s&&t.section===e.dataset.tab&&!t.element.closest("[hidden]"))).every((e=>!0===this.validateField(e.element)))}checkForSelectors(e){window.jvbSelector&&window.jvbSelector.scanExistingFields(e)}reindexList(e){const t=e.dataset.field||e.dataset.repeaterId||e.dataset.tagListId;Array.from(e.children).forEach(((e,s)=>{e.dataset.index=`${s}`;e.querySelectorAll("input, select, textarea").forEach((i=>{if("file"===i.type)return;i.dataset.field||i.name.split(":").pop();window.prefixInput(i,`${t}:${s}:`,e)}))})),this.updateCollectionField(e)}updateCollectionField(e){const t=e.closest("[data-field]");if(!t)return;const s=t.dataset.fieldType;if(!["repeater","tag-list"].includes(s))return;const i=this.getForm(e);if(!i)return;const a=this.getFieldValue(t.querySelector("input, select, textarea"));this.updateItem(t.dataset.field,a,i)}clearValidation(e){let t=this.getField(e);if(!t)return;let s=this.getItem(e);s&&(t.classList.remove("has-error","has-success"),s.ui.success&&(s.ui.success.hidden=!0),s.ui.error&&(s.ui.error.hidden=!0),s.ui.message&&(s.ui.message.hidden=!0,s.ui.message.textContent=""))}showError(e,t="Invalid field"){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-success"),s.classList.add("has-error"),i.ui.success&&(i.ui.success.hidden=!0),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=!1,i.ui.message.textContent=t))}showSuccess(e,t=""){let s=this.getField(e);if(!s)return;let i=this.getItem(e);i&&(s.classList.remove("has-error"),s.classList.add("has-success"),i.ui.success&&(i.ui.success.hidden=!1),i.ui.error&&(i.ui.error.hidden=!0),i.ui.message&&(i.ui.message.hidden=""===t,i.ui.message.textContent=t))}handleFormSuccess(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error").forEach((e=>e.classList.remove("field-error"))),e.classList.add("form-success"),t.message){const s=document.createElement("div");s.className="form-success-message success-message",s.textContent=t.message,e.insertBefore(s,e.firstChild);const i=window.getIcon?.("check-circle");i&&(i.classList.add("success-icon"),s.prepend(i))}if(t.title||t.description){const s=document.createElement("div");if(s.className="success-box",t.title){const e=document.createElement("h3");e.textContent=t.title,s.appendChild(e)}if(t.description){(Array.isArray(t.description)?t.description:[t.description]).forEach((e=>{const t=document.createElement("p");t.textContent=e,s.appendChild(t)}))}e.insertBefore(s,e.firstChild)}if(e.dataset.formId){this.store.delete(e.dataset.formId).catch((e=>{console.warn("Failed to clear form cache:",e)}));const t=this.forms.get(e.dataset.formId);t&&(t.isDirty=!1,t.lastSaved=Date.now(),t.data={})}window.jvbA11y&&window.jvbA11y.announce(t.message||"Form submitted successfully")}handleFormError(e,t){if(e.querySelectorAll(".error-message").forEach((e=>e.remove())),e.querySelectorAll(".field-error, .has-error").forEach((e=>{e.classList.remove("field-error","has-error")})),e.querySelectorAll(".field").forEach((e=>{this.clearValidation(e)})),t.field){const s=e.querySelector(`[data-field="${t.field}"]`);if(s){this.showError(s,t.message),this.touchedFields.add(t.field),s.scrollIntoView({behavior:"smooth",block:"center"});const e=s.querySelector("input, textarea, select");e&&e.focus()}}else{const s=document.createElement("div");s.className="form-error error-message",s.textContent=t.message;const i=window.getIcon?.("close-circle");i&&(i.classList.add("error-icon"),s.prepend(i)),e.insertBefore(s,e.firstChild),e.scrollIntoView({behavior:"smooth",block:"start"})}if(window.jvbA11y){const e=t.field?`Error in ${t.field}: ${t.message}`:`Form error: ${t.message}`;window.jvbA11y.announce(e)}e.dispatchEvent(new CustomEvent("jvb-form-error",{detail:t}))}showFormStatus(e,t,s=""){let i=this.forms.get(e);i&&i.options.showStatus&&i.ui?.status?.status&&i.status!==t&&(i.status=t,i.ui.status.status.hidden=!1,i.ui.status.status.classList.toggle("loading",["uploading","saving"].includes(t)),i.ui.status.message.textContent=""===s?this.getDefaultMessage(t):s,i.ui.status.icon.className="icon icon-"+this.getDefaultIcon(t),setTimeout((()=>i.ui.status.status.hidden=!0),"submitted"===t?3e3:1e4))}getDefaultMessage(e){return{saving:"Saving changes...",autosaved:"Changes saved locally. Submit form to send to server.",uploading:"Uploading your form to server",submitted:"Successfully sent to server",pending:"Unsaved changes",restored:"Welcome back! We've restored your previous entry.",error:"Failed to save changes. Refresh and try again?",offline:"Changes will be saved when online"}[e]??e}getDefaultIcon(e){return{autosaved:"check-circle",submitted:"check-circle",restored:"history",error:"close-circle",offline:"cloud-slash",pending:"exclamation-mark"}[e]??""}showSummary(e){let t=this.templates.create("formSummary",e);e.config.element.after(t),window.fade(e.config.element,!1)}getForm(e){let t=e.closest("[data-form-id]").dataset.formId;if(!t)return!1;let s=this.forms.get(t);return s||!1}getField(e){return e.closest("[data-field]")}getFieldType(e){let t=this.getField(e);if(t)return t.dataset.fieldType}getFieldValue(e){let t=this.getFieldType(e),s=this.getItem(e),i=s.field?.dataset.field??!1;if(!i)return!1;switch(t){case"repeater":return this.getRepeaterValue(e,s);case"tag-list":return this.getTagListValue(e,s);case"group":break;case"location":return this.getLocationValue(e,s);case"selector":case"upload":return this.getHiddenInputValue(e,s,i);case"true-false":return"1"===e.value||"on"===e.value||"true"===e.value;case"checkbox":return e.name.endsWith("[]")?this.getCheckboxGroupValue(e,s):e.checked?e.value:"";default:return e.value}}getCheckboxGroupValue(e,t){return t.checkboxGroup||(t.checkboxGroup=t.field?.querySelectorAll(`input[type="checkbox"][name="${e.name}"]`),this.saveItem(t)),Array.from(t.checkboxGroup).filter((e=>e.checked)).map((e=>e.value))}getFieldCheckedValue(e){if("checkbox"===e.type){return"true-false"===this.getFieldType(e)?e.checked:e.checked?e.value:""}if("radio"===e.type){const t=document.querySelectorAll(`input[name="${e.name}"]`),s=Array.from(t).find((e=>e.checked));return s?s.value:""}return this.getFieldValue(e)}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}getRepeaterValue(e,t){t.container||(t.container=t.field?.querySelector(".repeater-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t={};e.querySelectorAll("[data-field]").forEach((e=>{t[e.dataset.field]=this.getFieldValue(e)})),s.push(t)})),s}getTagListValue(e,t){t.container||(t.container=t.field?.querySelector(".tag-items"),this.saveItem(t));let s=[];return Array.from(t.container.children).forEach((e=>{let t=e.querySelectorAll('input[type="hidden"]'),i={};t.forEach((e=>{i[e.dataset.field]=e.value})),s.push(i)})),s}getLocationValue(e,t){t.values||(t.values=Array.from(t.field?.querySelectorAll("[data-location-field]")),this.saveItem(t));let s={};return t.values.forEach((e=>{s[e.dataset.locationField]=e.value})),s}getHiddenInputValue(e,t,s){return t.value||(t.value=t.field?.querySelector(`input[type=hidden][name="${s}"]`),this.saveItem(t)),t.value.value}formatValueForSummary(e,t){const s=this.getFieldType(t.element);if(this.isEmptyValue(e))return"";switch(s){case"repeater":return this.formatRepeaterForSummary(e,t);case"tag-list":return this.formatTagListForSummary(e,t);case"location":return this.formatLocationForSummary(e);case"true-false":return e?"Yes":"No";case"checkbox":return Array.isArray(e)?this.formatCheckboxGroupForSummary(e,t):this.getDisplayLabel(t,e);case"selector":case"upload":return this.formatHiddenFieldForSummary(e,t,s);default:return"string"==typeof e?this.getDisplayLabel(t,e):"string"==typeof e&&e.includes("\n")?this.convertLineBreaks(e):e}}formatCheckboxGroupForSummary(e,t){return e.map((e=>this.getDisplayLabel(t,e))).join(", ")}convertLineBreaks(e){const t=document.createElement("span");return t.innerHTML=e.split("\n").join("<br>"),t}formatRepeaterForSummary(e,t){const s=document.createElement("div");return s.className="summary-repeater",e.forEach(((e,i)=>{const a=document.createElement("div");a.className="summary-repeater-row";const r=document.createElement("strong");r.textContent=`Entry ${i+1}:`,a.appendChild(r);const n=document.createElement("ul");n.className="summary-repeater-fields";for(const[s,i]of Object.entries(e)){if(this.isEmptyValue(i))continue;const e=document.createElement("li"),a=t.field?.querySelector(`[data-field="${s}"]`),r=a?.closest(".field")?.querySelector("label")?.textContent.replace("*","").trim()||s;e.innerHTML=`<span class="field-label">${r}:</span> <span class="field-value">${i}</span>`,n.appendChild(e)}a.appendChild(n),s.appendChild(a)})),s}formatTagListForSummary(e,t){const s=document.createElement("div");s.className="summary-taglist";const i=document.createElement("ul");return i.className="summary-tags",e.forEach((e=>{const t=document.createElement("li");t.className="summary-tag";const s=Object.values(e).find((e=>!this.isEmptyValue(e)))||"",a=Object.entries(e).filter((([e,t])=>!this.isEmptyValue(t)));a.length>1?t.textContent=a.map((([e,t])=>t)).join(", "):t.textContent=s,i.appendChild(t)})),s.appendChild(i),s}formatLocationForSummary(e){const t=[];return e.street&&t.push(e.street),e.city&&t.push(e.city),e.province&&t.push(e.province),e.postal_code&&t.push(e.postal_code),e.country&&t.push(e.country),t.length>0?t.join(", "):e.address||""}formatHiddenFieldForSummary(e,t,s){if("upload"===s){const s=t.field?.querySelector("[data-upload-field]");if(s){const e=s.querySelectorAll(".item-grid.preview img");if(e.length>0){const t=document.createElement("div");return t.className="summary-uploads",e.forEach((e=>{const s=e.cloneNode(!0);s.style.maxWidth="100px",s.style.maxHeight="100px",t.appendChild(s)})),t}}return`${e.split(",").length} file(s) uploaded`}return e}getDisplayLabel(e,t){if(!e.element)return t;const s=e.element.type;if("radio"===s){const s=e.field.querySelectorAll(`input[type="radio"][name="${e.element.name}"]`),i=Array.from(s).find((e=>e.value===t));if(i){const t=i.closest("label")||e.field.querySelector(`label[for="${i.id}"]`);if(t)return t.textContent.replace("*","").trim()}}if("checkbox"===s&&"true-false"!==this.getFieldType(e.element)){const s=e.field.querySelector(`input[type="checkbox"][value="${t}"]`);if(s){const t=s.closest("label")||e.field.querySelector(`label[for="${s.id}"]`);if(t){const e=t.querySelector("span");return e?e.textContent.trim():t.textContent.replace("*","").trim()}}}return t}getItem(e,t=null){const s=Object.hasOwn(e.dataset,"ref");let i=s?e.dataset.ref:window.generateID("input");if(s||(e.dataset.ref=i),!this.inputs.has(i)){t||(t=e.closest("[data-form-id]")?.dataset.formId??!1);let s=this.getField(e);this.inputs.set(i,{id:i,element:e,form:t,field:s,section:e.closest("[data-tab]")?.dataset.tab??!1,ui:window.uiFromSelectors(this.selectors.fields,s)})}return this.inputs.get(i)}saveItem(e){this.inputs.set(e.id,e)}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("HandleSelection subscriber error:",e)}}))}destroy(){this.forms.size>0&&(Array.from(this.forms.values()).forEach((e=>{this.removeFormListeners(e)})),this.forms.clear()),this.repeaters.size>0&&(Array.from(this.repeaters.values()).forEach((e=>{this.removeRepeaterListeners(e.element),e.sortable?.destroy()})),this.repeaters.clear()),this.quantityFields.size>0&&(Array.from(this.quantityFields.values()).forEach((e=>{this.removeQuantityListeners(e.element)})),this.quantityFields.clear()),this.tagLists.size>0&&(Array.from(this.tagLists.values()).forEach((e=>{this.removeTagListListeners(e.element)})),this.tagLists.clear()),this.charLimits.size>0&&Array.from(this.charLimits.values()).forEach((e=>{e.removeEventListener("input",this.countUpdaters)})),this.inputs.clear(),this.forms.clear(),this.charLimits.clear()}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbForm=new e)}))}))})(); |
| | |
| | | (()=>{class e{constructor(){this.templates=window.jvbTemplates,this.formHelper=window.jvbForm,this.defineTemplates(),this.data=null,this.form=null}populate(e,t={}){if(this.data=t,this.mergeRootData(),this.form=e,this.formHelper||(this.formHelper=window.jvbForm),this.formHelper){if(Object.hasOwn(this.data,"fields")&&0!==Object.keys(this.data.fields).length)for(let[t,i]of Object.entries(this.data.fields)){let a=e.querySelector(`[data-field="${t}"]`);a&&this.populateField(a,t,i)}}else requestAnimationFrame((()=>{this.populate(e,t)}))}mergeRootData(){["status","date","modified"].forEach((e=>{this.data.fields[`post_${e}`]=this.data[e]}))}populateField(e,t,i){let a=this.formHelper.getFieldType(e);if(!a||this.isEmptyValue(t)||this.isEmptyValue(i))return;const l={repeater:this.populateRepeater.bind(this),"tag-list":this.populateTagList.bind(this),location:this.populateLocation.bind(this),selector:this.populateTaxonomy.bind(this),user:this.populateUser.bind(this),upload:this.populateUpload.bind(this),set:this.populateMultiValue.bind(this),checkbox:this.populateMultiValue.bind(this),select:this.populateSingleValue.bind(this),radio:this.populateSingleValue.bind(this),"true-false":this.populateBoolean.bind(this),date:this.populateDate.bind(this),time:this.populateDate.bind(this),datetime:this.populateDate.bind(this),number:this.populateNumber.bind(this),textarea:this.populateTextarea.bind(this)};Object.hasOwn(l,a)?l[a](e,t,i):this.populateText(e,t,i)}populateRepeater(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".repeater-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let o=i.querySelectorAll(".field");this.populate(o,e),a.append(i)})))}populateTagList(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".tag-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let o=i.querySelectorAll(".field");this.populate(o,e),a.append(i)})))}populateLocation(e,t,i){["address","lat","lng","street","city","province","postal_code","country"].forEach((t=>{if(Object.hasOwn(i,t)){let a=e.querySelector(`[data-location-field="${t}"]`);a&&(a.value=String(i[t]||""))}}))}populateTaxonomy(e,t,i){let a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector(`input[type="hidden"][name="${t}"]`);l&&(l.value=a.join(","),window.jvbSelector&&requestAnimationFrame((()=>{window.jvbSelector.updateFieldFromInput(l)})))}populateUser(e,t,i){this.populateTaxonomy(e,t,i)}populateUpload(e,t,i){if("timeline"===t||e.dataset.subtype&&"timeline"===e.dataset.subtype)return void this.populateTimelineGallery(e,t,i);if(this.isEmptyValue(i))return;const a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector('input[type="hidden"]');l&&(l.value=a.join(","));const o=e.querySelector(".item-grid");e.querySelector(".file-upload-container").hidden=a.length>0,e.querySelector(".progress")?.remove(),o&&(window.removeChildren(o),a.forEach((e=>{let t=this.data.images[e]??{};t.id=e,o.append(this.templates.create("uploadItem",t))}))),this.populateUploadMeta(e,t,i)}populateUploadMeta(e,t,i){const a=e.querySelector('[data-field="image_data"]');if(!a)return;let l=this.data.images[i]??!1;if(!l)return;a.dataset.attachmentId=l.id,a.setAttribute("data-ignore","");const o=["image-title","image-alt-text","image-caption"];for(const e of o){const t=a.querySelector(`[data-field="${e}"] input, [data-field="${e}"] textarea`);t&&""!==l[e]&&(t.value=l[e])}}populateTimelineGallery(e,t,i){if(!i||!Array.isArray(i)||0===i.length)return;let a=e.querySelector(".item-grid");if(e.querySelector(".file-upload-container").hidden=i.length>0,a){window.removeChildren(a),e.querySelector(".progress")?.remove();for(let e of i){let t=this.templates.create("timelineItem",e);t&&a.append(t)}}}populateMultiValue(e,t,i){if("string"==typeof i)try{i=JSON.parse(i)}catch(e){i=i.split(",").map((e=>e.trim()))}Array.isArray(i)||(i=[String(i)]);let a=e.querySelector(`select[name="${t}"]`);if(a&&a.multiple)for(let e of a.options)e.selected=i.includes(e.value);else e.querySelectorAll(`[type="checkbox"][name=${t}]`).forEach((e=>{e.checked=i.includes(e.value)}))}populateSingleValue(e,t,i){i=String(i||"");let a=e.querySelector(`select[name="${t}"]`);if(a)return void(a.value=i);let l=e.querySelector(`[name="${t}"][value="${i}"]`);l&&(l.checked=!0)}populateBoolean(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="checkbox"]`);a&&(a.checked=Boolean(i))}populateDate(e,t,i){const a=e.querySelector(`[name="${t}"], input`);if(a){"object"==typeof i&&Object.hasOwn(i,"date")&&(i=i.date);try{const e=new Date(i);if(!isNaN(e.getTime()))switch(a.type){case"date":a.value=e.toISOString().split("T")[0];break;case"time":a.value=e.toTimeString().slice(0,5);break;case"datetime-local":a.value=e.toISOString().slice(0,16);break;default:a.value=i}}catch(e){a.value=i}}}populateNumber(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="number"]`);a&&(a.value=Number(i)||0)}populateTextarea(e,t,i){let a=e.querySelector("textarea");a.dataset.editor?(a.value=String(i||""),a.dispatchEvent(new Event("change",{bubbles:!0}))):this.populateText(e,t,i)}populateText(e,t,i){let a=e.querySelector(`[name="${t}"], input, textarea`);a&&"file"!==a.type&&(a.value=String(i||""))}getFormHelper(){window.requestAnimationFrame((()=>{this.formHelper=window.jvbForm}))}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e)&&e>0))}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}defineTemplates(){const e=this.templates,t=this;e.define("timelineItem",{refs:{select:'[name="select-item"]',video:"video",file:".select-item span",img:"img",details:"details[data-field]",imgAlt:'[name="image-alt-text"]',imgTitle:'[name="image-title"]',imgDesc:'[name="image-caption"]'},manyRefs:{fields:".field"},setup({el:e,refs:i,manyRefs:a,data:l}){if(e.dataset.itemId=l.id,i.select){let e=i.select.closest(".preview");window.prefixInput(i.select,`${l.id}-`,e)}i.video&&i.video.remove(),i.file&&i.file.remove();let o=t.data.images[l.post_thumbnail]??!1;if(i.img&&o&&(i.img.src=o.medium||o.small||o.large||"",i.img.title=o["image-title"]??"",i.img.alt=o["image-alt-text"]??""),i.details){let e=t.data.images[l.post_thumbnail];i.details.setAttribute("data-ignore",""),i.details.dataset.attachmentId=l.post_thumbnail,Object.hasOwn(e,"image-alt-text")&&i.alt&&(i.alt.value=e["image-alt-text"]),(Object.hasOwn(e,"image-title")||Object.hasOwn(l,"file"))&&i.title&&(i.title.value=e["image-title"]||l.file.name),Object.hasOwn(e,"image-caption")&&i.description&&(i.description.value=e["image-caption"])}if(a.fields)for(let e of a.fields){if("group"===e.dataset.fieldType)continue;if("post_thumbnail"===e.dataset.field){e.remove();continue}let i=e.dataset.field,a=l[i]??"";t.isEmptyValue(a)||t.populateField(e,i,a);const o=e.querySelector('input:not([type="file"])');o&&window.prefixInput(o,`[${l.id}]`,e)}}})}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbPopulate=new e)}))}))})(); |
| | | (()=>{class e{constructor(){this.templates=window.jvbTemplates,this.formHelper=window.jvbForm,this.defineTemplates(),this.data=null,this.form=null}populate(e,t={}){if(this.data=t,this.mergeRootData(),this.form=e,this.formHelper||(this.formHelper=window.jvbForm),this.formHelper){if(Object.hasOwn(this.data,"fields")&&0!==Object.keys(this.data.fields).length)for(let[t,i]of Object.entries(this.data.fields)){let a=e.querySelector(`[data-field="${t}"]`);a&&this.populateField(a,t,i)}}else requestAnimationFrame((()=>{this.populate(e,t)}))}mergeRootData(){["status","date","modified"].forEach((e=>{this.data.fields[`post_${e}`]=this.data[e]}))}populateField(e,t,i){let a=this.formHelper.getFieldType(e);if(!a||this.isEmptyValue(t)||this.isEmptyValue(i))return;const l={repeater:this.populateRepeater.bind(this),"tag-list":this.populateTagList.bind(this),location:this.populateLocation.bind(this),selector:this.populateTaxonomy.bind(this),user:this.populateUser.bind(this),upload:this.populateUpload.bind(this),set:this.populateMultiValue.bind(this),checkbox:this.populateMultiValue.bind(this),select:this.populateSingleValue.bind(this),radio:this.populateSingleValue.bind(this),"true-false":this.populateBoolean.bind(this),date:this.populateDate.bind(this),time:this.populateDate.bind(this),datetime:this.populateDate.bind(this),number:this.populateNumber.bind(this),textarea:this.populateTextarea.bind(this)};Object.hasOwn(l,a)?l[a](e,t,i):this.populateText(e,t,i)}populateRepeater(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".repeater-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let o=i.querySelectorAll(".field");this.populate(o,e),a.append(i)})))}populateTagList(e,t,i){if(!i||!Array.isArray(i))return;const a=e.querySelector(".tag-items");let l=e.querySelector("template")?.className??!1;a&&l&&(window.removeChildren(a),i.forEach(((e,t)=>{e.index=t;const i=this.templates.create(l,e);let o=i.querySelectorAll(".field");this.populate(o,e),a.append(i)})))}populateLocation(e,t,i){["address","lat","lng","street","city","province","postal_code","country"].forEach((t=>{if(Object.hasOwn(i,t)){let a=e.querySelector(`[data-location-field="${t}"]`);a&&(a.value=String(i[t]||""))}}))}populateTaxonomy(e,t,i){let a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector(`input[type="hidden"][name="${t}"]`);l&&(l.value=a.join(","),window.jvbSelector&&requestAnimationFrame((()=>{window.jvbSelector.updateFieldFromInput(l)})))}populateUser(e,t,i){this.populateTaxonomy(e,t,i)}populateUpload(e,t,i){if("timeline"===t||e.dataset.subtype&&"timeline"===e.dataset.subtype)return void this.populateTimelineGallery(e,t,i);if(this.isEmptyValue(i))return;const a=this.splitIDs(i);if(0===a.length)return;const l=e.querySelector('input[type="hidden"]');l&&(l.value=a.join(","));const o=e.querySelector(".item-grid");e.querySelector(".file-upload-container").hidden=a.length>0,e.querySelector(".progress")?.remove(),o&&(window.removeChildren(o),a.forEach((e=>{let t=this.data.images[e]??{};t.field={config:{showMeta:!0}},t.id=e,o.append(this.templates.create("uploadItem",t))}))),this.populateUploadMeta(e,t,i)}populateUploadMeta(e,t,i){const a=e.querySelector('[data-field="image_data"]');if(!a)return;let l=this.data.images[i]??!1;if(!l)return;a.dataset.attachmentId=l.id,a.setAttribute("data-ignore","");const o=["image-title","image-alt-text","image-caption"];for(const e of o){const t=a.querySelector(`[data-field="${e}"] input, [data-field="${e}"] textarea`);t&&""!==l[e]&&(t.value=l[e])}}populateTimelineGallery(e,t,i){if(!i||!Array.isArray(i)||0===i.length)return;let a=e.querySelector(".item-grid");if(e.querySelector(".file-upload-container").hidden=i.length>0,a){window.removeChildren(a),e.querySelector(".progress")?.remove();for(let e of i){let t=this.templates.create("timelineItem",e);t&&a.append(t)}}}populateMultiValue(e,t,i){if("string"==typeof i)try{i=JSON.parse(i)}catch(e){i=i.split(",").map((e=>e.trim()))}Array.isArray(i)||(i=[String(i)]);let a=e.querySelector(`select[name="${t}"]`);if(a&&a.multiple)for(let e of a.options)e.selected=i.includes(e.value);else e.querySelectorAll(`[type="checkbox"][name=${t}]`).forEach((e=>{e.checked=i.includes(e.value)}))}populateSingleValue(e,t,i){i=String(i||"");let a=e.querySelector(`select[name="${t}"]`);if(a)return void(a.value=i);let l=e.querySelector(`[name="${t}"][value="${i}"]`);l&&(l.checked=!0)}populateBoolean(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="checkbox"]`);a&&(a.checked=Boolean(i))}populateDate(e,t,i){const a=e.querySelector(`[name="${t}"], input`);if(a){"object"==typeof i&&Object.hasOwn(i,"date")&&(i=i.date);try{const e=new Date(i);if(!isNaN(e.getTime()))switch(a.type){case"date":a.value=e.toISOString().split("T")[0];break;case"time":a.value=e.toTimeString().slice(0,5);break;case"datetime-local":a.value=e.toISOString().slice(0,16);break;default:a.value=i}}catch(e){a.value=i}}}populateNumber(e,t,i){const a=e.querySelector(`[name="${t}"], input[type="number"]`);a&&(a.value=Number(i)||0)}populateTextarea(e,t,i){let a=e.querySelector("textarea");a.dataset.editor?(a.value=String(i||""),a.dispatchEvent(new Event("change",{bubbles:!0}))):this.populateText(e,t,i)}populateText(e,t,i){let a=e.querySelector(`[name="${t}"], input, textarea`);a&&"file"!==a.type&&(a.value=String(i||""))}getFormHelper(){window.requestAnimationFrame((()=>{this.formHelper=window.jvbForm}))}splitIDs(e){return String(e).split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e)&&e>0))}isEmptyValue(e){return null==e||""===e||(!(!Array.isArray(e)||0!==e.length)||"object"==typeof e&&0===Object.keys(e).length)}defineTemplates(){const e=this.templates,t=this;e.define("timelineItem",{refs:{select:'[name="select-item"]',video:"video",file:".select-item span",img:"img",details:"details[data-field]",imgAlt:'[name="image-alt-text"]',imgTitle:'[name="image-title"]',imgDesc:'[name="image-caption"]'},manyRefs:{fields:".field"},setup({el:e,refs:i,manyRefs:a,data:l}){if(e.dataset.itemId=l.id,i.select){let e=i.select.closest(".preview");window.prefixInput(i.select,`${l.id}-`,e)}i.video&&i.video.remove(),i.file&&i.file.remove();let o=t.data.images[l.post_thumbnail]??!1;if(i.img&&o&&(i.img.src=o.medium||o.small||o.large||"",i.img.title=o["image-title"]??"",i.img.alt=o["image-alt-text"]??""),i.details){let e=t.data.images[l.post_thumbnail];i.details.setAttribute("data-ignore",""),i.details.dataset.attachmentId=l.post_thumbnail,Object.hasOwn(e,"image-alt-text")&&i.alt&&(i.alt.value=e["image-alt-text"]),(Object.hasOwn(e,"image-title")||Object.hasOwn(l,"file"))&&i.title&&(i.title.value=e["image-title"]||l.file.name),Object.hasOwn(e,"image-caption")&&i.description&&(i.description.value=e["image-caption"])}if(a.fields)for(let e of a.fields){if("group"===e.dataset.fieldType)continue;if("post_thumbnail"===e.dataset.field){e.remove();continue}let i=e.dataset.field,a=l[i]??"";t.isEmptyValue(a)||t.populateField(e,i,a);const o=e.querySelector('input:not([type="file"])');o&&window.prefixInput(o,`[${l.id}]`,e)}}})}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbPopulate=new e)}))}))})(); |
| | |
| | | window.jvbQuill=function(t){const n=t.querySelectorAll("textarea[data-editor=true]"),e=[];return n.forEach((t=>{let n,i,l;if(t.parentNode.querySelector(".editor-container"))n=t.parentNode.querySelector(".editor-container"),i=n.querySelector(".editor"),l=n.querySelector(".toolbar");else{n=document.createElement("div"),n.className="editor-container",i=document.createElement("div"),i.className="editor",l=document.createElement("div"),l.className="toolbar";const e=!0===t.dataset.allowimage?`<button type="button" class="ql-jvb_image">\n ${window.getIcon("image")}\n </button>`:"";l.id=`toolbar-${t.id}`,l.innerHTML=`\n <span class="ql-formats">\n <button type="button" class="ql-p">\n <i class="icon icon-paragraph"></i>\n </button>\n <button type="button" class="ql-h1">\n <i class="icon icon-text-h-one"></i>\n </button>\n <button type="button" class="ql-h2">\n <i class="icon icon-text-h-two"></i>\n </button>\n <button type="button" class="ql-h3">\n <i class="icon icon-text-h-three"></i>\n </button>\n </span>\n <span class="ql-formats">\n <button type="button" class="ql-jvb_bold">\n <i class="icon icon-text-b-fi"></i>\n </button>\n <button type="button" class="ql-jvb_italic">\n <i class="icon icon-text-italic"></i>\n </button>\n <button type="button" class="ql-jvb_underline">\n <i class="icon icon-text-underline"></i>\n </button>\n <button type="button" class="ql-jvb_strike">\n <i class="icon icon-text-strikethrough"></i>\n </button>\n </span>\n <span class="ql-formats">\n <button type="button" class="ql-jvb_list" value="bullet">\n <i class="icon icon-list-dashes"></i>\n </button>\n <button type="button" class="ql-jvb_list" value="ordered">\n <i class="icon icon-list-numbers"></i>\n </button>\n </span>\n <span class="ql-formats">\n <button type="button" class="ql-jvb_align" value="left">\n <i class="icon icon-text-align-left"></i>\n </button>\n <button type="button" class="ql-jvb_align" value="center">\n <i class="icon icon-text-align-center"></i>\n </button>\n <button type="button" class="ql-jvb_align" value="right">\n <i class="icon icon-text-align-right"></i>\n </button>\n </span>\n <span class="ql-formats">\n <button type="button" class="ql-jvb_link">\n <i class="icon icon-link"></i>\n </button>\n ${e}\n </span>\n `,n.appendChild(l),n.appendChild(i),t.parentNode.insertBefore(n,t),t.style.display="none",i.innerHTML=t.value}const o=new Quill(i,{theme:"snow",modules:{toolbar:{container:l,handlers:{p:function(){this.quill.format("header",!1)},h1:function(){this.quill.format("header",1)},h2:function(){this.quill.format("header",2)},h3:function(){this.quill.format("header",3)},jvb_bold:function(){this.quill.format("bold",!0)},jvb_italic:function(){this.quill.format("italic",!0)},jvb_strike:function(){this.quill.format("strike",!0)},jvb_underline:function(){this.quill.format("underline",!0)},jvb_align:function(t){this.quill.format("align",t!==this.quill.getFormat().list&&t)},jvb_list:function(t){this.quill.format("list",t!==this.quill.getFormat().list&&t)},jvb_link:function(t){if(t){const t=this.quill.getSelection();if(null==t||0===t.length)return;const n=this.quill.getFormat(t).link,e=document.createElement("dialog");e.className="quill-link-modal",e.innerHTML=`\n <div class="quill-link-modal-content ">\n <label for="link">Enter URL</label>\n <input type="url" id="link" placeholder="Enter URL" value="${n||""}" />\n <div class="buttons">\n <button type="button" class="save">Save</button>\n ${n?'<button type="button" class="remove">Remove</button>':""}\n <button type="button" class="cancel">Cancel</button>\n </div>\n </div>\n `,document.body.appendChild(e),e.showModal();const i=e.querySelector("input");i.focus(),e.querySelector(".save").addEventListener("click",(()=>{const t=i.value;t&&this.quill.format("link",t),e.remove()}));const l=e.querySelector(".remove");l&&l.addEventListener("click",(()=>{this.quill.format("link",!1),e.remove()})),e.querySelector(".cancel").addEventListener("click",(()=>{e.remove()})),i.addEventListener("keyup",(t=>{if("Enter"===t.key){const t=i.value;t&&this.quill.format("link",t),e.remove()}}))}},jvb_image:function(){const t=document.createElement("input");t.setAttribute("type","file"),t.setAttribute("accept","image/jpeg,image/png,image/gif,image/webp"),t.style.display="none",document.body.appendChild(t),t.onchange=async n=>{const e=n.target.files?.[0];if(!e)return;if(e.size>5242880)return this.quill.insertText(i.index,"File too large. Maximum size is 5MB",{color:"#f00",italic:!0},!0),void t.remove();const i=this.quill.getSelection(!0),l=new FormData;l.append("image",e),objectID&&l.append("post_id",objectID);try{const t=await fetch(`${jvbSettings.api}uploads/`,{method:"POST",headers:{"X-WP-Nonce":window.auth.getNonce()},body:l});if(!t.ok)throw new Error("Upload failed");const n=await t.json();this.quill.insertEmbed(i.index,"image",n.url)}catch(t){this.handleError("Upload error:",t),this.quill.insertText(i.index,"Failed to upload image. Please try again.",{color:"#f00",italic:!0},!0)}finally{t.remove()}},t.click()}}},history:{delay:2e3,maxStack:500},clipboard:{matchVisual:!1}}});e.push(o),o.on("selection-change",(function(t){const n=l.querySelector(".ql-align");if(n){if(t&&0===t.length){const[e]=this.quill.getLeaf(t.index);if(e&&e.domNode&&"IMG"===e.domNode.tagName)return void(n.style.display="inline-block")}n.style.display="none"}})),o.on("text-change",(()=>{t.value=o.root.innerHTML,t.dispatchEvent(new Event("change",{bubbles:!0}))}))})),e}; |
| | | window.jvbQuill=function(t){const n=t.querySelectorAll("textarea[data-editor=true]"),e=[];return n.forEach((t=>{let n,i,l;if(t.parentNode.querySelector(".editor-container"))n=t.parentNode.querySelector(".editor-container"),i=n.querySelector(".editor"),l=n.querySelector(".toolbar");else{n=document.createElement("div"),n.className="editor-container",i=document.createElement("div"),i.className="editor",l=document.createElement("div"),l.className="toolbar";const e=!0===t.dataset.allowimage?`<button type="button" class="ql-jvb_image">\n ${window.getIcon("image")}\n </button>`:"";l.id=`toolbar-${t.id}`,l.innerHTML=`\n <span class="ql-formats">\n <button type="button" class="ql-p">\n <i class="icon icon-paragraph"></i>\n </button>\n <button type="button" class="ql-h1">\n <i class="icon icon-text-h-one"></i>\n </button>\n <button type="button" class="ql-h2">\n <i class="icon icon-text-h-two"></i>\n </button>\n <button type="button" class="ql-h3">\n <i class="icon icon-text-h-three"></i>\n </button>\n </span>\n <span class="ql-formats">\n <button type="button" class="ql-jvb_bold">\n <i class="icon icon-text-b-fi"></i>\n </button>\n <button type="button" class="ql-jvb_italic">\n <i class="icon icon-text-italic"></i>\n </button>\n <button type="button" class="ql-jvb_underline">\n <i class="icon icon-text-underline"></i>\n </button>\n <button type="button" class="ql-jvb_strike">\n <i class="icon icon-text-strikethrough"></i>\n </button>\n </span>\n <span class="ql-formats">\n <button type="button" class="ql-jvb_list" value="bullet">\n <i class="icon icon-list-dashes"></i>\n </button>\n <button type="button" class="ql-jvb_list" value="ordered">\n <i class="icon icon-list-numbers"></i>\n </button>\n </span>\n <span class="ql-formats">\n <button type="button" class="ql-jvb_align" value="left">\n <i class="icon icon-text-align-left"></i>\n </button>\n <button type="button" class="ql-jvb_align" value="center">\n <i class="icon icon-text-align-center"></i>\n </button>\n <button type="button" class="ql-jvb_align" value="right">\n <i class="icon icon-text-align-right"></i>\n </button>\n </span>\n <span class="ql-formats">\n <button type="button" class="ql-jvb_link">\n <i class="icon icon-link"></i>\n </button>\n ${e}\n </span>\n `,n.appendChild(l),n.appendChild(i),t.parentNode.insertBefore(n,t),t.style.display="none",i.innerHTML=t.value}const o=new Quill(i,{theme:"snow",modules:{toolbar:{container:l,handlers:{p:function(){this.quill.format("header",!1)},h1:function(){this.quill.format("header",1)},h2:function(){this.quill.format("header",2)},h3:function(){this.quill.format("header",3)},jvb_bold:function(){const t=this.quill.getFormat();this.quill.format("bold",!t.bold)},jvb_italic:function(){const t=this.quill.getFormat();this.quill.format("italic",!t.italic)},jvb_strike:function(){const t=this.quill.getFormat();this.quill.format("strike",!t.strike)},jvb_underline:function(){const t=this.quill.getFormat();this.quill.format("underline",!t.underline)},jvb_align:function(t){const n=this.quill.getFormat();this.quill.format("align",t!==n.align&&t)},jvb_list:function(t){const n=this.quill.getFormat();this.quill.format("list",t!==n.list&&t)},jvb_link:function(t){if(t){const t=this.quill.getSelection();if(null==t||0===t.length)return;const n=this.quill.getFormat(t).link,e=document.createElement("dialog");e.className="quill-link-modal",e.innerHTML=`\n <div class="quill-link-modal-content ">\n <label for="link">Enter URL</label>\n <input type="url" id="link" placeholder="Enter URL" value="${n||""}" />\n <div class="buttons">\n <button type="button" class="save">Save</button>\n ${n?'<button type="button" class="remove">Remove</button>':""}\n <button type="button" class="cancel">Cancel</button>\n </div>\n </div>\n `,document.body.appendChild(e),e.showModal();const i=e.querySelector("input");i.focus(),e.querySelector(".save").addEventListener("click",(()=>{const t=i.value;t&&this.quill.format("link",t),e.remove()}));const l=e.querySelector(".remove");l&&l.addEventListener("click",(()=>{this.quill.format("link",!1),e.remove()})),e.querySelector(".cancel").addEventListener("click",(()=>{e.remove()})),i.addEventListener("keyup",(t=>{if("Enter"===t.key){const t=i.value;t&&this.quill.format("link",t),e.remove()}}))}},jvb_image:function(){const n=t.dataset.postId||t.closest("form")?.dataset.postId,e=document.createElement("input");e.setAttribute("type","file"),e.setAttribute("accept","image/jpeg,image/png,image/gif,image/webp"),e.style.display="none",document.body.appendChild(e),e.onchange=async t=>{const i=t.target.files?.[0];if(!i)return;if(i.size>5242880)return this.quill.insertText(l.index,"File too large. Maximum size is 5MB",{color:"#f00",italic:!0},!0),void e.remove();const l=this.quill.getSelection(!0),o=new FormData;o.append("image",i),n&&o.append("post_id",n);try{const t=await fetch(`${jvbSettings.api}uploads/`,{method:"POST",headers:{"X-WP-Nonce":window.auth.getNonce()},body:o});if(!t.ok)throw new Error("Upload failed");const n=await t.json();this.quill.insertEmbed(l.index,"image",n.url)}catch(t){console.error("Upload error:",t),this.quill.insertText(l.index,"Failed to upload image. Please try again.",{color:"#f00",italic:!0},!0)}finally{e.remove()}},e.click()}}},history:{delay:2e3,maxStack:500},clipboard:{matchVisual:!1}}});e.push(o),o.on("selection-change",(function(t){if(!t)return;const n=o.getFormat(t);Object.entries({"ql-jvb_bold":"bold","ql-jvb_italic":"italic","ql-jvb_underline":"underline","ql-jvb_strike":"strike"}).forEach((([t,e])=>{const i=l.querySelector(`.${t}`);i&&i.classList.toggle("active",!!n[e])})),l.querySelectorAll(".ql-jvb_list").forEach((t=>{const e=t.getAttribute("value");t.classList.toggle("ql-active",n.list===e)})),l.querySelectorAll(".ql-jvb_align").forEach((t=>{const e=t.getAttribute("value");t.classList.toggle("ql-active",n.align===e)}));const e=l.querySelector(".ql-align");if(e){if(0===t.length){const[n]=this.quill.getLeaf(t.index);if(n&&n.domNode&&"IMG"===n.domNode.tagName)return void(e.style.display="inline-block")}e.style.display="none"}})),o.on("text-change",(()=>{t.value=o.root.innerHTML,t.dispatchEvent(new Event("change",{bubbles:!0}))}))})),e}; |
| | |
| | | (()=>{class e{constructor(){this.container=document.querySelector("dialog#jvb-selector"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.subscribers=new Set,this.fields=new Map,this.selectedTerms=new Map,this.batchFetch=new Set,this.activeField=null,this.isInitializing=!0,this.lazyInit=!1,this.messageText={},this.init())}init(){this.initStore(),this.initElements(),this.defineTemplates(),this.initModal(),this.scanExistingFields(),this.initListeners(),this.needsCreator()&&window.jvbTaxCreator&&(this.creator=new window.jvbTaxCreator(this)),this.isInitializing=!1,this.batchFetchTaxonomies().then((()=>{}))}initStore(){const e=window.jvbStore.register("taxonomies",{storeName:"terms",keyPath:"id",showLoading:!1,indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug"},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:12e4,filters:{taxonomy:"",page:1,search:"",parent:0},required:"taxonomy",delayFetch:!0});this.store=e.terms,this.store.subscribe(this.handleStoreEvent.bind(this))}defineTemplates(){const e=window.jvbTemplates,t=this;e.define("emptyState"),e.define("selectedTerm",{refs:{name:".item-name",btn:"button"},setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.dataset.taxonomy=i.taxonomy,t.name&&(t.name.textContent=i.path),t.button&&(t.button.title=`Remove ${i.name}`)}}),e.define("termListItem",{refs:{checkbox:"input",label:"label",name:"span, .term-name"},setup({el:e,refs:s,manyRefs:i,data:r}){e.dataset.id=r.id;let a=t.currentField(),n=t.selectedTerms.get(t.activeField).has(r.id),o=a.limit>0&&t.selectedTerms.get(t.activeField).size>=a.limit;if(s.checkbox&&(s.checkbox.dataset.id=r.id,s.checkbox.id=`${a.id}-${r.id}`,s.checkbox.name=`${a.id}-${a.taxonomy}-select`,s.checkbox.value=r.id,s.checkbox.disabled=!n&&o,s.checkbox.checked=n),s.label&&(s.label.htmlFor=`${a.id}-${r.id}`,s.label.title=r.path??r.name,s.label.dataset.path=r.path),s.name&&(s.name.textContent=r.show?r.path:r.name),r.hasChildren){let t={plural:a.plural,name:r.name};const s=window.jvbTemplates.create("termChildrenToggle",t);e.append(s)}}}),e.define("termChildrenToggle",{setup({el:e,refs:t,manyRefs:s,data:i}){e.ariaLabel=`View ${i.plural} nested under ${i.name}`}}),e.define("termBreadcrumb",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.name,e.title=i.name}}),e.define("autocompleteItem",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.path||i.name,e.title=`Select ${i.name}`}})}initElements(){this.selectors={search:{input:'[type="search"]',clear:".clear-search",container:".search-wrapper",results:".search-results"},create:{button:"button.submit-term",span:".submit-term span"},terms:{list:".items-container",wrap:".items-wrap",sentinel:".scroll-sentinel"},nav:{nav:"nav.term-navigation",back:".back-to-parent",child:".toggle-children",pathLevel:".path-level"},message:{message:"p.message",text:"p.message span"},selected:".selected-items",modal:{title:"#modal-title",content:".modal-content",count:".selection-count"},favourites:".favourite-terms",field:{toggle:'button.taxonomy-toggle, [data-filter="taxonomy"]',value:'input[type="hidden"]',selected:".selected-items",dropdown:{list:".search-results",wrapper:".auto-wrapper"},create:{button:".auto-wrapper .submit-term",span:".auto-wrapper button span"},search:"input[data-autocomplete]",message:{message:"p.message",text:"p.message span"}}},this.ui=window.uiFromSelectors(this.selectors,this.container)}initListeners(){this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.nextPage()}))}),{root:this.ui.terms.sentinel,threshold:.5}),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.inputHandler=this.handleInput.bind(this),this.focusHandler=this.handleFocus.bind(this),this.blurHandler=this.handleBlur.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("input",this.inputHandler),document.addEventListener("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0)}handleClick(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target)||this.activeField,s=this.fields.get(t);if(!t||!s)return;if(this.creator){window.targetCheck(e,this.selectors.create.button)&&this.maybeCreateTerm(e).then((()=>{}))}const i=window.targetCheck(e,".item.autocomplete");if(i){let e=parseInt(i.dataset.id);return this.addSelected(e,t),this.scheduleHideDropdown(t,6e3),void(s.ui.search&&(s.ui.search.value=""))}if(window.targetCheck(e,this.selectors.field.toggle))return e.preventDefault(),void this.openModal(t);const r=window.targetCheck(e,".remove-term");if(r){const e=r.closest("[data-id]").dataset.id??!1;return void(t&&e&&this.removeSelected(parseInt(e),t))}if(e.target.matches(".modal-close"))return this.updateFieldValue(t),void this.modal?.handleClose();if(window.targetCheck(e,this.selectors.nav.back))return void this.navigateToParent();if(window.targetCheck(e,this.selectors.nav.child)){const t=e.target.closest("li"),s=parseInt(t.dataset.id);return void(s&&this.navigateTo(s))}const a=window.targetCheck(e,this.selectors.nav.pathLevel);if(a){const e=parseInt(a.dataset.id)??0;return void this.navigateTo(e)}if(window.targetCheck(e,this.selectors.field.dropdown))return void this.scheduleHideDropdown(t);if(window.targetCheck(e,this.selectors.search.clear)){const e=this.currentField();e&&e.ui.search&&(e.ui.search.value="",this.store.setFilters({search:"",page:1,parent:this.store.filters.parent||0})),this.ui.search.input&&(this.ui.search.input.value="")}}handleChange(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;if(!["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.dataset.id);let s=this.getFieldId(e.target);e.target.checked?this.addSelected(t,s):this.removeSelected(t,s)}handleInput(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;let t=this.getFieldId(e.target)??this.activeField;if(!t)return;const s=this.fields.get(t);if(!s)return;if(["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation(),this.container.open||this.setField(t);let i=e.target.value.trim();this.setMessage(s,!0,`Searching for "${i}" in ${s.plural??"items"}`),window.debouncer.schedule(`${t}-search`,(async()=>{this.container.open&&window.removeChildren(this.ui.terms.list),await this.store.setFilters({taxonomy:s.taxonomy,search:i,page:1,parent:i?0:this.store.filters.parent||0})}),100)}setField(e){const t=this.fields.get(e);t?(this.activeField=e,this.setMessage(t,!0,`Loading ${t.plural}...`),this.resetFilters({taxonomy:t.taxonomy})):console.error("No field found...")}resetFilters(e){Object.hasOwn(e,"taxonomy")&&(e={page:1,search:"",parent:0,...e},this.store.setFilters(e))}handleFocus(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&(s.hasAutocomplete||s.hasSearch)&&(window.debouncer.cancel(`${t}-search-results`),this.container.open||this.setField(t))}handleBlur(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&s.hasAutocomplete&&!this.container.open&&(e.relatedTarget&&s.ui.dropdown.wrapper?.contains(e.relatedTarget)||this.scheduleHideDropdown(t))}scheduleHideDropdown(e,t=1500){const s=this.fields.get(e);s&&window.debouncer.schedule(`${e}-search-results`,(()=>{this.container.open||(this.activeField=null),s.ui.dropdown.wrapper&&(s.ui.dropdown.wrapper.hidden=!0)}),t)}initModal(){this.modalID="dialog#jvb-selector",this.container=document.querySelector(this.modalID),this.modal=new window.jvbModal(this.container,{handleForm:!1,open:null}),this.modal.subscribe(((e,t)=>{if("modal-close"===e)this.closeModal()}))}toggleModal(e,t=!0){this.fields.get(e)&&(t?this.openModal(e):this.closeModal())}openModal(e){const t=this.fields.get(e);if(!t)return;this.setField(e),this.ui.modal.title.textContent=t.isFilter?`Filter by ${t.singular}`:`Select ${t.plural}`,this.ui.search.container&&(this.ui.search.container.hidden=!t.canSearch),this.creator&&this.creator.handleOpen(t);let s=`Opened ${t.singular} selection. Choose from checkboxes, or search to filter results.`;window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen(),this.a11y.announce(s)}openEmpty(e,t,s,i){this.emptyCallback=i;const r=`empty-${e}-${Date.now()}`;this.fields.has(r)||(this.fields.set(r,{id:r,taxonomy:e,singular:t,plural:s,canSearch:!0,canCreate:!1,hasAutocomplete:!1,isFilter:!1,isEmpty:!0,limit:0,ui:{},element:null,value:null,toggle:null,checked:!0}),this.selectedTerms.set(r,new Set)),this.setField(r),this.ui.modal.title.textContent=`Add to ${s}`,this.ui.search?.container&&(this.ui.search.container.hidden=!1),window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen()}closeModal(){const e=this.fields.get(this.activeField);if(!e)return;if(this.updateFieldValue(this.activeField),this.observer.unobserve(this.ui.terms.sentinel),window.removeChildren(this.ui.terms.list),e.isEmpty&&this.emptyCallback){const t=Array.from(this.selectedTerms.get(this.activeField)||[]),s=t.map((e=>this.store.get(e))).filter(Boolean);this.emptyCallback({taxonomy:e.taxonomy,termIds:t,terms:s}),this.fields.delete(this.activeField),this.selectedTerms.delete(this.activeField),this.emptyCallback=null,this.bulkAssignmentTaxonomy=null}else this.notify("selected-terms",{terms:this.selectedTerms.get(this.activeField),taxonomy:e.taxonomy});this.activeField=null;let t=`Closed ${e.singular} selector.`;this.a11y.announce(t)}navigateToParent(){const e=this.store.filters.parent;if(0===e)return;let t=this.store.get(parseInt(e));if(!t)return void this.navigateTo(0);let s=t.parent;this.navigateTo(parseInt(s))}navigateTo(e=0){e=parseInt(e)??0,this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.terms.list),this.updateBreadcrumbs(e)}nextPage(){let e=this.store.filters.page,t=Math.min(e++,this.store.lastResponse.total);this.store.setFilters({page:t})}prevPage(){let e=this.store.filters.page,t=Math.max(e-1,1);this.store.setFilters({page:t})}addTermToModal(e){const t=this.store.get(e);if(!t)return;this.currentField()&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||this.ui.selected.append(this.getSelectedTermUI(t)))}getSelectedTermUI(e,t=!0){return window.jvbTemplates.create("selectedTerm",e)}scanExistingFields(e=document.body){e.querySelectorAll('[data-type="selector"], [data-field-type="selector"]').forEach((e=>{try{e.dataset.lazy?this.lazyInit=!0:this.registerField(e)}catch(t){this.error.log(t,{component:"TaxonomySelector",action:"scanExistingFields",container:e.dataset.name})}})),this.lazyInit&&this.initObserver(e)}unregisterFields(e){e.querySelectorAll('[data-type="selector"],[data-field-type="selector"]').forEach((e=>{this.fields.delete(e.dataset.fieldId)}))}initObserver(e){this.lazyObserver=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&e.target.dataset.lazy&&(delete e.target.dataset.lazy,this.registerField(e.target),this.lazyObserver.unobserve(e.target))}))}),{rootMargin:"50px"}),e.querySelectorAll('[data-type="selector"][data-lazy], [data-field-type="selector"][data-lazy]').forEach((e=>{this.lazyObserver.observe(e)}))}registerField(e,t={}){if(e.dataset.fieldId&&this.fields.has(e.dataset.fieldId))return e.dataset.fieldId;let s=e.querySelector('input[type="hidden"]');if(!s&&!Object.hasOwn(e.dataset,"filter"))return;"fieldId"in e.dataset||(e.dataset.fieldId=window.generateID("selector"));const i=e.dataset.fieldId;let r=this.selectors.field;const a=Object.hasOwn(e.dataset,"filter")&&"taxonomy"===e.dataset.filter;let n=a?e:e.querySelector("button.taxonomy-toggle");if(0===Object.keys(t).length){if(!n)return;t={taxonomy:n.dataset.taxonomy,single:n.dataset.single,plural:n.dataset.plural,search:Object.hasOwn(n.dataset,"search"),autocomplete:Object.hasOwn(n.dataset,"autocomplete"),creatable:Object.hasOwn(n.dataset,"creatable")}}else Object.hasOwn(t,"toggle")&&(n=document.querySelector(t.toggle),r.toggle=t.toggle);const o={id:i,value:s,element:e,taxonomy:t.taxonomy??!1,singular:t.single??"",plural:t.plural??"",name:e.dataset.field,canSearch:t.search??!1,limit:t.limit??0,hasAutocomplete:t.autocomplete??!1,canCreate:t.creatable??!1,isRequired:t.required??!1,isFilter:a,toggle:n,create:{button:null,span:null},selectors:r,ui:window.uiFromSelectors(r,e),checked:!1};if(a&&!o.ui.toggle&&(o.ui.toggle=e),o.taxonomy)return o.singular&&o.plural||(console.warn("TaxonomySelector: Field missing singular/plural labels",e),o.singular=o.taxonomy.replace("jvb_",""),o.plural=o.singular+"s"),this.fields.set(i,o),this.setSelectedFromValue(i,s),this.isInitializing&&this.batchFetch.add(o.taxonomy),null!==e.offsetParent?this.updateFieldUI(i):requestIdleCallback((()=>{null!==e.offsetParent&&this.updateFieldUI(i)}),{timeout:2e3}),i;console.error("TaxonomySelector: Field missing taxonomy",e)}setSelectedFromValue(e,t){if(!e)return;let s=this.fields.get(e);if(!s)return;if(!t&&!s.isFilter)return;let i=new Set;t&&t.value.trim().split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>i.add(e))),this.selectedTerms.set(e,i)}addSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;const r=this.selectedTerms.get(t);0!==s.limit&&r.size>=s.limit||(r.add(parseInt(e)),this.container.open||s.isFilter||this.updateFieldValue(t),this.addTermToDisplay(e,t),this.checkLimits(t))}removeSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;this.selectedTerms.get(t).delete(parseInt(e));const r=!!s.ui.selected&&s.ui.selected.querySelector(`[data-id="${e}"]`);if(r&&r.remove(),this.container.open){let t=!!this.ui.selected&&this.ui.selected.querySelector(`[data-id="${e}"]`);t&&t.remove();let s=this.ui.terms.list.querySelector(`[type=checkbox][data-id="${e}"]`);s&&(s.checked=!1)}this.container.open||s.isFilter||this.updateFieldValue(t),this.checkLimits(t)}updateFieldValue(e){const t=this.fields.get(e);if(!t)return;let s=Array.from(this.selectedTerms.get(e));t.ui.value&&(t.ui.value.value=s.join(",")??"",t.ui.value.dispatchEvent(new Event("change",{bubbles:!0})))}checkLimits(e){if(!this.container.open)return;const t=this.fields.get(e);if(!t||!t.isFilter||0===t.limit)return;const s=this.selectedTerms.get(e).size>=t.limit;this.setCheckboxes(s)}updateFieldFromInput(e){const t=this.getFieldId(e);if(!t)return;this.fields.get(t)&&(this.setSelectedFromValue(t,e),this.updateFieldUI(t))}updateFieldUI(e){const t=this.fields.get(e);let s=this.selectedTerms.get(e)??new Set;t&&!t.isFilter&&0!==s.size&&Array.from(s).forEach((t=>{this.addTermToDisplay(t,e)}))}updateFieldsForTaxonomy(e){let t=Array.from(this.fields.values()).filter((t=>t.taxonomy===e));const s=Array.from(this.store.data.values()).some((t=>t&&t.taxonomy===e));t.forEach((e=>{e.toggle&&(e.toggle.disabled=!s&&!e.canCreate,e.toggle.title=s?`Select ${e.plural}`:`No ${e.singular} available`,e.checked=!0)}))}showModalTerms(e=!1){const t=this.currentField(),s=this.store.getFiltered();if(0===s.length)return(this.store.filters.page??1)&&window.removeChildren(this.ui.terms.list),this.setMessage(t,!0,""===this.store.filters.search?`No matching ${t.plural}.`:`No ${t.plural} found.`,!1),void(this.ui.terms.sentinel&&this.observer.unobserve(this.ui.terms.sentinel));this.setCreateButton(t,!0),this.ui.terms.sentinel&&(this.store.lastResponse?.has_more?this.observer.observe(this.ui.terms.sentinel):this.observer.unobserve(this.ui.terms.sentinel));const i=this.store.filters.parent??0;this.ui.nav.back.hidden=0===i,window.chunkIt(s,(t=>this.createTermElement({show:e,...t})),(e=>this.ui.terms.list.append(e)),10).then((()=>{})),s.length>0&&this.setMessage(t,!1)}createTermElement(e){return e&&e.name?window.jvbTemplates.create("termListItem",e):null}showAutocompleteTerms(){const e=this.currentField();if(!e||!e.hasAutocomplete||!e.ui.dropdown?.list)return;const t=e.ui.dropdown.list,s=this.currentTerms();window.removeChildren(t),0===s.length?this.setMessage(e,!0,`No ${e.plural} found.`,!1):(window.chunkIt(s,(e=>this.createAutocompleteTerm(e)),(e=>t.append(e))).then((()=>{})),this.setMessage(e,!1)),this.setCreateButton(e,!0),e.ui.dropdown.wrapper&&(e.ui.dropdown.wrapper.hidden=!1)}createAutocompleteTerm(e){return window.jvbTemplates.create("autocompleteItem",e)}addTermToDisplay(e,t){const s=this.store.get(e),i=this.fields.get(t);if(!s||!i)return;if(i.ui.selected&&i.ui.selected.querySelector(`[data-id="${e}"]`))return;let r=this.getSelectedTermUI(s);if(i.ui.selected&&i.ui.selected.append(r),this.container.open){this.addTermToModal(e);const t=this.ui.terms.list.querySelector(`input[value="${e}"]`);t&&(t.checked=!0)}}updateBreadcrumbs(e){const t=this.ui.nav.nav;if(!t)return;const s=Array.from(t.children).find((t=>parseInt(t.dataset.id)===e));if(s){let e=s.nextElementSibling;for(;e;){const t=e;e=e.nextElementSibling,t.remove()}}else{const s=this.store.get(e);if(!s)return;const i=window.jvbTemplates.create("termBreadcrumb",s);t.append(i)}}updateSelectionCount(){if(!this.container.open)return;const e=this.fields.get(this.activeField);if(e&&this.ui.modal.count){const t=this.selectedTerms.get(this.activeField).size;this.ui.modal.count.textContent=e.limit>0?`${t} of ${e.limit} ${e.plural} selected`:`${t} ${e.plural} selected`}}checkRendered(e,t){if(e)return Object.hasOwn(e,t.taxonomy)||(e[t.taxonomy]=new Map),e[t.taxonomy].has(t.id)}currentField(){return this.fields.get(this.activeField)??!1}currentTerms(){return this.store.getFiltered()}needsCreator(){return Array.from(this.fields.values()).some((e=>e.canCreate||e.hasAutocomplete))}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?.dataset.fieldId||null}setCheckboxes(e){this.ui.terms.list.querySelectorAll("input[type=checkbox]").forEach((t=>{t.checked||(t.disabled=e)}))}handleStoreEvent(e,t){const s={"data-loaded":()=>this.handleDataLoaded(),"filters-changed":()=>this.handleFiltersChanged(t),"fetch-error":()=>this.handleFetchError()};try{s[e]?.(t)}catch(t){console.error(`Error handling store event "${e}":`,t)}}handleDataLoaded(){const e=this.store.filters.taxonomy;if(e){e.split(",").map((e=>e.trim())).forEach((e=>this.updateFieldsForTaxonomy(e)))}this.container.open?this.showResults():this.activeField&&this.showResults(!0)}showResults(e=!1){const t=this.store.getFiltered(),s=this.store.filters,i=s.search&&s.search.length>0;this.notify("terms-loaded",{terms:t,filters:s}),!this.activeField&&e||(this.setMessage(this.currentField(),!1),e?this.showAutocompleteTerms():this.showModalTerms(i),this.a11y.announce(t.length))}handleFiltersChanged(e){}handleFetchError(e){const t=this.currentField(),s=t?`Failed to load ${t.plural}`:"Failed to load data";this.setMessage(t,!0,s,!1),console.error("Store fetch error:",e)}async batchFetchTaxonomies(){if(0===this.batchFetch.size)return;const e=Array.from(this.batchFetch);this.batchFetch.clear();try{await this.store.setFilters({taxonomy:e.join(","),page:1,search:"",parent:0})}catch(e){console.error("Failed to batch fetch taxonomies:",e)}}preloadTaxonomy(e){this.store.setFilters({taxonomy:e,page:1,search:"",parent:0})}setCreateButton(e,t=!0){if(!e.canCreate||!this.creator)return;const s=this.container.open?this.ui:e.ui;if(!s.create?.button||!s.create?.span)return;const i=s.create.button;i.hidden=!t;const r=s.create.span,a=this.container.open?s.search.input:s.search;if(!a)return;let n=(this.currentTerms()??[]).map((e=>e.name)),o=a.value;const l=t&&o.length>=2&&!n.includes(o);i.hidden=!l,l&&(r.textContent=a.value??"")}async maybeCreateTerm(e){const t=this.currentField();if(!t)return;window.debouncer.cancel(`${t.id}-search-results`);let s={taxonomy:t.taxonomy,parent:this.store.filters.parent??0};if(this.container.open&&""===this.ui.search.input.value?(s.parent=this.creator.ui.parent.value??s.parent,s.name=this.creator.ui.name.value??!1):s.name=this.container.open?this.ui.search.input.value:t.ui.search.value,void 0!==s.parent&&s.name){this.setMessage(t,!0,`Creating "${s.name}"...`),this.setCreateButton(t,!1),this.container.open?window.removeChildren(this.ui.terms.list):(t.ui.search.disabled=!0,t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!1));let e=await this.creator.handleTermCreation(s);if(e){if(this.setMessage(t,!0,`"${e.name}" created!`,!1),this.addSelected(e.id,t.id),this.updateFieldValue(t.id),!this.container.open&&t.ui.dropdown.list){window.removeChildren(t.ui.dropdown.list);const s=this.createAutocompleteTerm(e);s&&(s.classList.add("newly-created"),t.ui.dropdown.list.append(s))}this.scheduleHideDropdown(t.id,300),this.setMessage(t,!1)}else this.setMessage(t,!1),!this.container.open&&t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!0);this.container.open||(t.ui.search.disabled=!1,t.ui.search.value="")}}setMessage(e,t=!0,s="",i=!0){const r=this.container.open||e.isFilter?this.ui:e.isFilter?null:e.ui;if(!r?.message?.message)return;s=""===s?`No ${e.plural??"items"} found.`:s;const a=r.message.message,n=r.message.text;a.hidden=!t,t?s&&n&&(i&&window.typeLoop&&n?(this.messageText[e.id]&&(this.messageText[e.id](),delete this.messageText[e.id]),this.messageText[e.id]=window.typeLoop(n,s)):n.textContent=s):this.messageText[e.id]&&(this.messageText[e.id](),delete this.messageText[e.id])}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){this.fields.forEach(((e,t)=>{window.debouncer.cancel(`${t}-search`),window.debouncer.cancel(`${t}-search-results`)})),Object.keys(this.messageText).forEach((e=>{this.messageText[e]&&this.messageText[e]()})),this.messageText={},this.ui.terms?.sentinel&&this.observer?.unobserve(this.ui.terms.sentinel),this.observer?.disconnect(),this.lazyObserver?.disconnect(),document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("input",this.inputHandler),document.removeEventListener("focus",this.focusHandler,!0),document.removeEventListener("blur",this.blurHandler,!0),this.subscribers.clear(),this.fields.clear(),this.selectedTerms.clear(),this.batchFetch.clear(),this.creator&&(this.creator.destroy(),this.creator=null),this.store&&(this.store=null)}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbSelector=new e)}))}))})(); |
| | | (()=>{class e{constructor(){this.container=document.querySelector("dialog#jvb-selector"),this.container&&(this.a11y=window.jvbA11y,this.error=window.jvbError,this.subscribers=new Set,this.fields=new Map,this.selectedTerms=new Map,this.batchFetch=new Set,this.activeField=null,this.isInitializing=!0,this.lazyInit=!1,this.messageText={},this.init())}init(){this.initStore(),this.initElements(),this.defineTemplates(),this.initModal(),this.scanExistingFields(),this.initListeners(),this.needsCreator()&&window.jvbTaxCreator&&(this.creator=new window.jvbTaxCreator(this)),this.isInitializing=!1,this.batchFetchTaxonomies().then((()=>{}))}initStore(){const e=window.jvbStore.register("taxonomies",{storeName:"terms",keyPath:"id",showLoading:!1,indexes:[{name:"taxonomy",keyPath:"taxonomy"},{name:"parent",keyPath:"parent"},{name:"slug",keyPath:"slug"},{name:"count",keyPath:"count"}],endpoint:"terms",TTL:12e4,filters:{taxonomy:"",page:1,search:"",parent:0},required:"taxonomy",delayFetch:!0});this.store=e.terms,this.store.subscribe(this.handleStoreEvent.bind(this))}defineTemplates(){const e=window.jvbTemplates,t=this;e.define("emptyState"),e.define("selectedTerm",{refs:{name:".item-name",btn:"button"},setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.dataset.taxonomy=i.taxonomy,t.name&&(t.name.textContent=i.path),t.button&&(t.button.title=`Remove ${i.name}`)}}),e.define("termListItem",{refs:{checkbox:"input",label:"label",name:"span, .term-name"},setup({el:e,refs:s,manyRefs:i,data:r}){e.dataset.id=r.id;let a=t.currentField(),n=t.selectedTerms.get(t.activeField).has(r.id),o=a.limit>0&&t.selectedTerms.get(t.activeField).size>=a.limit;if(s.checkbox&&(s.checkbox.dataset.id=r.id,s.checkbox.id=`${a.id}-${r.id}`,s.checkbox.name=`${a.id}-${a.taxonomy}-select`,s.checkbox.value=r.id,s.checkbox.disabled=!n&&o,s.checkbox.checked=n),s.label&&(s.label.htmlFor=`${a.id}-${r.id}`,s.label.title=r.path??r.name,s.label.dataset.path=r.path),s.name&&(s.name.textContent=r.show?r.path:r.name),r.hasChildren){let t={plural:a.plural,name:r.name};const s=window.jvbTemplates.create("termChildrenToggle",t);e.append(s)}}}),e.define("termChildrenToggle",{setup({el:e,refs:t,manyRefs:s,data:i}){e.ariaLabel=`View ${i.plural} nested under ${i.name}`}}),e.define("termBreadcrumb",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.name,e.title=i.name}}),e.define("autocompleteItem",{setup({el:e,refs:t,manyRefs:s,data:i}){e.dataset.id=i.id,e.textContent=i.path||i.name,e.title=`Select ${i.name}`}})}initElements(){this.selectors={search:{input:'[type="search"]',clear:".clear-search",container:".search-wrapper",results:".search-results"},create:{button:"button.submit-term",span:".submit-term span"},terms:{list:".items-container",wrap:".items-wrap",sentinel:".scroll-sentinel"},nav:{nav:"nav.term-navigation",back:".back-to-parent",child:".toggle-children",pathLevel:".path-level"},message:{message:"p.message",text:"p.message span"},selected:".selected-items",modal:{title:"#modal-title",content:".modal-content",count:".selection-count"},favourites:".favourite-terms",field:{toggle:'button.selector-toggle, [data-filter="taxonomy"]',value:'input[type="hidden"]',selected:".selected-items",dropdown:{list:".search-results",wrapper:".auto-wrapper"},create:{button:".auto-wrapper .submit-term",span:".auto-wrapper button span"},search:"input[data-autocomplete]",message:{message:"p.message",text:"p.message span"}}},this.ui=window.uiFromSelectors(this.selectors,this.container)}initListeners(){this.observer=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&this.nextPage()}))}),{root:this.ui.terms.sentinel,threshold:.5}),this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.inputHandler=this.handleInput.bind(this),this.focusHandler=this.handleFocus.bind(this),this.blurHandler=this.handleBlur.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("input",this.inputHandler),document.addEventListener("focus",this.focusHandler,!0),document.addEventListener("blur",this.blurHandler,!0)}handleClick(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target)||this.activeField,s=this.fields.get(t);if(!t||!s)return;if(this.creator){window.targetCheck(e,this.selectors.create.button)&&this.maybeCreateTerm(e).then((()=>{}))}const i=window.targetCheck(e,".item.autocomplete");if(i){let e=parseInt(i.dataset.id);return this.addSelected(e,t),this.scheduleHideDropdown(t,6e3),void(s.ui.search&&(s.ui.search.value=""))}if(window.targetCheck(e,this.selectors.field.toggle))return e.preventDefault(),void this.openModal(t);const r=window.targetCheck(e,".remove-term");if(r){const e=r.closest("[data-id]").dataset.id??!1;return void(t&&e&&this.removeSelected(parseInt(e),t))}if(e.target.matches(".modal-close"))return this.updateFieldValue(t),void this.modal?.handleClose();if(window.targetCheck(e,this.selectors.nav.back))return void this.navigateToParent();if(window.targetCheck(e,this.selectors.nav.child)){const t=e.target.closest("li"),s=parseInt(t.dataset.id);return void(s&&this.navigateTo(s))}const a=window.targetCheck(e,this.selectors.nav.pathLevel);if(a){const e=parseInt(a.dataset.id)??0;return void this.navigateTo(e)}if(window.targetCheck(e,this.selectors.field.dropdown))return void this.scheduleHideDropdown(t);if(window.targetCheck(e,this.selectors.search.clear)){const e=this.currentField();e&&e.ui.search&&(e.ui.search.value="",this.store.setFilters({search:"",page:1,parent:this.store.filters.parent||0})),this.ui.search.input&&(this.ui.search.input.value="")}}handleChange(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;if(!["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation();const t=parseInt(e.target.dataset.id);let s=this.getFieldId(e.target);e.target.checked?this.addSelected(t,s):this.removeSelected(t,s)}handleInput(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;let t=this.getFieldId(e.target)??this.activeField;if(!t)return;const s=this.fields.get(t);if(!s)return;if(["checkbox","button"].includes(e.target.type))return;e.preventDefault(),e.stopPropagation(),this.container.open||this.setField(t);let i=e.target.value.trim();this.setMessage(s,!0,`Searching for "${i}" in ${s.plural??"items"}`),window.debouncer.schedule(`${t}-search`,(async()=>{this.container.open&&window.removeChildren(this.ui.terms.list),await this.store.setFilters({taxonomy:s.taxonomy,search:i,page:1,parent:i?0:this.store.filters.parent||0})}),100)}setField(e){const t=this.fields.get(e);t?(this.activeField=e,this.setMessage(t,!0,`Loading ${t.plural}...`),this.resetFilters({taxonomy:t.taxonomy})):console.error("No field found...")}resetFilters(e){Object.hasOwn(e,"taxonomy")&&(e={page:1,search:"",parent:0,...e},this.store.setFilters(e))}handleFocus(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&(s.hasAutocomplete||s.hasSearch)&&(window.debouncer.cancel(`${t}-search-results`),this.container.open||this.setField(t))}handleBlur(e){if(!this.container.contains(e.target)&&!e.target.closest('[data-type="selector"], [data-field-type="selector"]'))return;const t=this.getFieldId(e.target);if(!t)return;const s=this.fields.get(t);s&&s.hasAutocomplete&&!this.container.open&&(e.relatedTarget&&s.ui.dropdown.wrapper?.contains(e.relatedTarget)||this.scheduleHideDropdown(t))}scheduleHideDropdown(e,t=1500){const s=this.fields.get(e);s&&window.debouncer.schedule(`${e}-search-results`,(()=>{this.container.open||(this.activeField=null),s.ui.dropdown.wrapper&&(s.ui.dropdown.wrapper.hidden=!0)}),t)}initModal(){this.modalID="dialog#jvb-selector",this.container=document.querySelector(this.modalID),this.modal=new window.jvbModal(this.container,{handleForm:!1,open:null}),this.modal.subscribe(((e,t)=>{if("modal-close"===e)this.closeModal()}))}toggleModal(e,t=!0){this.fields.get(e)&&(t?this.openModal(e):this.closeModal())}openModal(e){const t=this.fields.get(e);if(!t)return;this.setField(e),this.ui.modal.title.textContent=t.isFilter?`Filter by ${t.singular}`:`Select ${t.plural}`,this.ui.search.container&&(this.ui.search.container.hidden=!t.canSearch),this.creator&&this.creator.handleOpen(t);let s=`Opened ${t.singular} selection. Choose from checkboxes, or search to filter results.`;window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen(),this.a11y.announce(s)}openEmpty(e,t,s,i){this.emptyCallback=i;const r=`empty-${e}-${Date.now()}`;this.fields.has(r)||(this.fields.set(r,{id:r,taxonomy:e,singular:t,plural:s,canSearch:!0,canCreate:!1,hasAutocomplete:!1,isFilter:!1,isEmpty:!0,limit:0,ui:{},element:null,value:null,toggle:null,checked:!0}),this.selectedTerms.set(r,new Set)),this.setField(r),this.ui.modal.title.textContent=`Add to ${s}`,this.ui.search?.container&&(this.ui.search.container.hidden=!1),window.removeChildren(this.ui.selected),window.removeChildren(this.ui.terms.list),this.modal.handleOpen()}closeModal(){const e=this.fields.get(this.activeField);if(!e)return;if(this.updateFieldValue(this.activeField),this.observer.unobserve(this.ui.terms.sentinel),window.removeChildren(this.ui.terms.list),e.isEmpty&&this.emptyCallback){const t=Array.from(this.selectedTerms.get(this.activeField)||[]),s=t.map((e=>this.store.get(e))).filter(Boolean);this.emptyCallback({taxonomy:e.taxonomy,termIds:t,terms:s}),this.fields.delete(this.activeField),this.selectedTerms.delete(this.activeField),this.emptyCallback=null,this.bulkAssignmentTaxonomy=null}else this.notify("selected-terms",{terms:this.selectedTerms.get(this.activeField),taxonomy:e.taxonomy});this.activeField=null;let t=`Closed ${e.singular} selector.`;this.a11y.announce(t)}navigateToParent(){const e=this.store.filters.parent;if(0===e)return;let t=this.store.get(parseInt(e));if(!t)return void this.navigateTo(0);let s=t.parent;this.navigateTo(parseInt(s))}navigateTo(e=0){e=parseInt(e)??0,this.store.setFilters({parent:e,page:1}),window.removeChildren(this.ui.terms.list),this.updateBreadcrumbs(e)}nextPage(){let e=this.store.filters.page,t=Math.min(e++,this.store.lastResponse.total);this.store.setFilters({page:t})}prevPage(){let e=this.store.filters.page,t=Math.max(e-1,1);this.store.setFilters({page:t})}addTermToModal(e){const t=this.store.get(e);if(!t)return;this.currentField()&&(this.ui.selected.querySelector(`[data-id="${e}"]`)||this.ui.selected.append(this.getSelectedTermUI(t)))}getSelectedTermUI(e,t=!0){return window.jvbTemplates.create("selectedTerm",e)}scanExistingFields(e=document.body){e.querySelectorAll('[data-type="selector"], [data-field-type="selector"]').forEach((e=>{try{e.dataset.lazy?this.lazyInit=!0:this.registerField(e)}catch(t){this.error.log(t,{component:"TaxonomySelector",action:"scanExistingFields",container:e.dataset.name})}})),this.lazyInit&&this.initObserver(e)}unregisterFields(e){e.querySelectorAll('[data-type="selector"],[data-field-type="selector"]').forEach((e=>{this.fields.delete(e.dataset.fieldId)}))}initObserver(e){this.lazyObserver=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting&&e.target.dataset.lazy&&(delete e.target.dataset.lazy,this.registerField(e.target),this.lazyObserver.unobserve(e.target))}))}),{rootMargin:"50px"}),e.querySelectorAll('[data-type="selector"][data-lazy], [data-field-type="selector"][data-lazy]').forEach((e=>{this.lazyObserver.observe(e)}))}registerField(e,t={}){if(e.dataset.fieldId&&this.fields.has(e.dataset.fieldId))return e.dataset.fieldId;let s=e.querySelector('input[type="hidden"]');if(!s&&!Object.hasOwn(e.dataset,"filter"))return;"fieldId"in e.dataset||(e.dataset.fieldId=window.generateID("selector"));const i=e.dataset.fieldId;let r=this.selectors.field;const a=Object.hasOwn(e.dataset,"filter")&&"taxonomy"===e.dataset.filter;let n=a?e:e.querySelector("button.taxonomy-toggle");if(0===Object.keys(t).length){if(!n)return;t={taxonomy:n.dataset.taxonomy,single:n.dataset.single,plural:n.dataset.plural,search:Object.hasOwn(n.dataset,"search"),autocomplete:Object.hasOwn(n.dataset,"autocomplete"),creatable:Object.hasOwn(n.dataset,"creatable")}}else Object.hasOwn(t,"toggle")&&(n=document.querySelector(t.toggle),r.toggle=t.toggle);const o={id:i,value:s,element:e,taxonomy:t.taxonomy??!1,singular:t.single??"",plural:t.plural??"",name:e.dataset.field,canSearch:t.search??!1,limit:t.limit??0,hasAutocomplete:t.autocomplete??!1,canCreate:t.creatable??!1,isRequired:t.required??!1,isFilter:a,toggle:n,create:{button:null,span:null},selectors:r,ui:window.uiFromSelectors(r,e),checked:!1};if(a&&!o.ui.toggle&&(o.ui.toggle=e),o.taxonomy)return o.singular&&o.plural||(console.warn("TaxonomySelector: Field missing singular/plural labels",e),o.singular=o.taxonomy.replace("jvb_",""),o.plural=o.singular+"s"),this.fields.set(i,o),this.setSelectedFromValue(i,s),this.isInitializing&&this.batchFetch.add(o.taxonomy),null!==e.offsetParent?this.updateFieldUI(i):requestIdleCallback((()=>{null!==e.offsetParent&&this.updateFieldUI(i)}),{timeout:2e3}),i;console.error("TaxonomySelector: Field missing taxonomy",e)}setSelectedFromValue(e,t){if(!e)return;let s=this.fields.get(e);if(!s)return;if(!t&&!s.isFilter)return;let i=new Set;t&&t.value.trim().split(",").map((e=>parseInt(e.trim()))).filter((e=>!isNaN(e))).forEach((e=>i.add(e))),this.selectedTerms.set(e,i)}addSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;const r=this.selectedTerms.get(t);0!==s.limit&&r.size>=s.limit||(r.add(parseInt(e)),this.container.open||s.isFilter||this.updateFieldValue(t),this.addTermToDisplay(e,t),this.checkLimits(t))}removeSelected(e,t=null){t||(t=this.activeField);const s=this.fields.get(t),i=this.store.get(e);if(!s||!i)return;this.selectedTerms.get(t).delete(parseInt(e));const r=!!s.ui.selected&&s.ui.selected.querySelector(`[data-id="${e}"]`);if(r&&r.remove(),this.container.open){let t=!!this.ui.selected&&this.ui.selected.querySelector(`[data-id="${e}"]`);t&&t.remove();let s=this.ui.terms.list.querySelector(`[type=checkbox][data-id="${e}"]`);s&&(s.checked=!1)}this.container.open||s.isFilter||this.updateFieldValue(t),this.checkLimits(t)}updateFieldValue(e){const t=this.fields.get(e);if(!t)return;let s=Array.from(this.selectedTerms.get(e));t.ui.value&&(t.ui.value.value=s.join(",")??"",t.ui.value.dispatchEvent(new Event("change",{bubbles:!0})))}checkLimits(e){if(!this.container.open)return;const t=this.fields.get(e);if(!t||!t.isFilter||0===t.limit)return;const s=this.selectedTerms.get(e).size>=t.limit;this.setCheckboxes(s)}updateFieldFromInput(e){const t=this.getFieldId(e);if(!t)return;this.fields.get(t)&&(this.setSelectedFromValue(t,e),this.updateFieldUI(t))}updateFieldUI(e){const t=this.fields.get(e);let s=this.selectedTerms.get(e)??new Set;t&&!t.isFilter&&0!==s.size&&Array.from(s).forEach((t=>{this.addTermToDisplay(t,e)}))}updateFieldsForTaxonomy(e){let t=Array.from(this.fields.values()).filter((t=>t.taxonomy===e));const s=Array.from(this.store.data.values()).some((t=>t&&t.taxonomy===e));t.forEach((e=>{e.toggle&&(e.toggle.disabled=!s&&!e.canCreate,e.toggle.title=s?`Select ${e.plural}`:`No ${e.singular} available`,e.checked=!0)}))}showModalTerms(e=!1){const t=this.currentField(),s=this.store.getFiltered();if(0===s.length)return(this.store.filters.page??1)&&window.removeChildren(this.ui.terms.list),this.setMessage(t,!0,""===this.store.filters.search?`No matching ${t.plural}.`:`No ${t.plural} found.`,!1),void(this.ui.terms.sentinel&&this.observer.unobserve(this.ui.terms.sentinel));this.setCreateButton(t,!0),this.ui.terms.sentinel&&(this.store.lastResponse?.has_more?this.observer.observe(this.ui.terms.sentinel):this.observer.unobserve(this.ui.terms.sentinel));const i=this.store.filters.parent??0;this.ui.nav.back.hidden=0===i,window.chunkIt(s,(t=>this.createTermElement({show:e,...t})),(e=>this.ui.terms.list.append(e)),10).then((()=>{})),s.length>0&&this.setMessage(t,!1)}createTermElement(e){return e&&e.name?window.jvbTemplates.create("termListItem",e):null}showAutocompleteTerms(){const e=this.currentField();if(!e||!e.hasAutocomplete||!e.ui.dropdown?.list)return;const t=e.ui.dropdown.list,s=this.currentTerms();window.removeChildren(t),0===s.length?this.setMessage(e,!0,`No ${e.plural} found.`,!1):(window.chunkIt(s,(e=>this.createAutocompleteTerm(e)),(e=>t.append(e))).then((()=>{})),this.setMessage(e,!1)),this.setCreateButton(e,!0),e.ui.dropdown.wrapper&&(e.ui.dropdown.wrapper.hidden=!1)}createAutocompleteTerm(e){return window.jvbTemplates.create("autocompleteItem",e)}addTermToDisplay(e,t){const s=this.store.get(e),i=this.fields.get(t);if(!s||!i)return;if(i.ui.selected&&i.ui.selected.querySelector(`[data-id="${e}"]`))return;let r=this.getSelectedTermUI(s);if(i.ui.selected&&i.ui.selected.append(r),this.container.open){this.addTermToModal(e);const t=this.ui.terms.list.querySelector(`input[value="${e}"]`);t&&(t.checked=!0)}}updateBreadcrumbs(e){const t=this.ui.nav.nav;if(!t)return;const s=Array.from(t.children).find((t=>parseInt(t.dataset.id)===e));if(s){let e=s.nextElementSibling;for(;e;){const t=e;e=e.nextElementSibling,t.remove()}}else{const s=this.store.get(e);if(!s)return;const i=window.jvbTemplates.create("termBreadcrumb",s);t.append(i)}}updateSelectionCount(){if(!this.container.open)return;const e=this.fields.get(this.activeField);if(e&&this.ui.modal.count){const t=this.selectedTerms.get(this.activeField).size;this.ui.modal.count.textContent=e.limit>0?`${t} of ${e.limit} ${e.plural} selected`:`${t} ${e.plural} selected`}}checkRendered(e,t){if(e)return Object.hasOwn(e,t.taxonomy)||(e[t.taxonomy]=new Map),e[t.taxonomy].has(t.id)}currentField(){return this.fields.get(this.activeField)??!1}currentTerms(){return this.store.getFiltered()}needsCreator(){return Array.from(this.fields.values()).some((e=>e.canCreate||e.hasAutocomplete))}getFieldId(e){if(e.dataset.fieldId)return e.dataset.fieldId;const t=e.closest("[data-field-id]");return t?.dataset.fieldId||null}setCheckboxes(e){this.ui.terms.list.querySelectorAll("input[type=checkbox]").forEach((t=>{t.checked||(t.disabled=e)}))}handleStoreEvent(e,t){const s={"data-loaded":()=>this.handleDataLoaded(),"filters-changed":()=>this.handleFiltersChanged(t),"fetch-error":()=>this.handleFetchError()};try{s[e]?.(t)}catch(t){console.error(`Error handling store event "${e}":`,t)}}handleDataLoaded(){const e=this.store.filters.taxonomy;if(e){e.split(",").map((e=>e.trim())).forEach((e=>this.updateFieldsForTaxonomy(e)))}this.container.open?this.showResults():this.activeField&&this.showResults(!0)}showResults(e=!1){const t=this.store.getFiltered(),s=this.store.filters,i=s.search&&s.search.length>0;this.notify("terms-loaded",{terms:t,filters:s}),!this.activeField&&e||(this.setMessage(this.currentField(),!1),e?this.showAutocompleteTerms():this.showModalTerms(i),this.a11y.announce(t.length))}handleFiltersChanged(e){}handleFetchError(e){const t=this.currentField(),s=t?`Failed to load ${t.plural}`:"Failed to load data";this.setMessage(t,!0,s,!1),console.error("Store fetch error:",e)}async batchFetchTaxonomies(){if(0===this.batchFetch.size)return;const e=Array.from(this.batchFetch);this.batchFetch.clear();try{await this.store.setFilters({taxonomy:e.join(","),page:1,search:"",parent:0})}catch(e){console.error("Failed to batch fetch taxonomies:",e)}}preloadTaxonomy(e){this.store.setFilters({taxonomy:e,page:1,search:"",parent:0})}setCreateButton(e,t=!0){if(!e.canCreate||!this.creator)return;const s=this.container.open?this.ui:e.ui;if(!s.create?.button||!s.create?.span)return;const i=s.create.button;i.hidden=!t;const r=s.create.span,a=this.container.open?s.search.input:s.search;if(!a)return;let n=(this.currentTerms()??[]).map((e=>e.name)),o=a.value;const l=t&&o.length>=2&&!n.includes(o);i.hidden=!l,l&&(r.textContent=a.value??"")}async maybeCreateTerm(e){const t=this.currentField();if(!t)return;window.debouncer.cancel(`${t.id}-search-results`);let s={taxonomy:t.taxonomy,parent:this.store.filters.parent??0};if(this.container.open&&""===this.ui.search.input.value?(s.parent=this.creator.ui.parent.value??s.parent,s.name=this.creator.ui.name.value??!1):s.name=this.container.open?this.ui.search.input.value:t.ui.search.value,void 0!==s.parent&&s.name){this.setMessage(t,!0,`Creating "${s.name}"...`),this.setCreateButton(t,!1),this.container.open?window.removeChildren(this.ui.terms.list):(t.ui.search.disabled=!0,t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!1));let e=await this.creator.handleTermCreation(s);if(e){if(this.setMessage(t,!0,`"${e.name}" created!`,!1),this.addSelected(e.id,t.id),this.updateFieldValue(t.id),!this.container.open&&t.ui.dropdown.list){window.removeChildren(t.ui.dropdown.list);const s=this.createAutocompleteTerm(e);s&&(s.classList.add("newly-created"),t.ui.dropdown.list.append(s))}this.scheduleHideDropdown(t.id,300),this.setMessage(t,!1)}else this.setMessage(t,!1),!this.container.open&&t.ui.dropdown.wrapper&&(t.ui.dropdown.wrapper.hidden=!0);this.container.open||(t.ui.search.disabled=!1,t.ui.search.value="")}}setMessage(e,t=!0,s="",i=!0){const r=this.container.open||e.isFilter?this.ui:e.isFilter?null:e.ui;if(!r?.message?.message)return;s=""===s?`No ${e.plural??"items"} found.`:s;const a=r.message.message,n=r.message.text;a.hidden=!t,t?s&&n&&(i&&window.typeLoop&&n?(this.messageText[e.id]&&(this.messageText[e.id](),delete this.messageText[e.id]),this.messageText[e.id]=window.typeLoop(n,s)):n.textContent=s):this.messageText[e.id]&&(this.messageText[e.id](),delete this.messageText[e.id])}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){this.fields.forEach(((e,t)=>{window.debouncer.cancel(`${t}-search`),window.debouncer.cancel(`${t}-search-results`)})),Object.keys(this.messageText).forEach((e=>{this.messageText[e]&&this.messageText[e]()})),this.messageText={},this.ui.terms?.sentinel&&this.observer?.unobserve(this.ui.terms.sentinel),this.observer?.disconnect(),this.lazyObserver?.disconnect(),document.removeEventListener("click",this.clickHandler),document.removeEventListener("change",this.changeHandler),document.removeEventListener("input",this.inputHandler),document.removeEventListener("focus",this.focusHandler,!0),document.removeEventListener("blur",this.blurHandler,!0),this.subscribers.clear(),this.fields.clear(),this.selectedTerms.clear(),this.batchFetch.clear(),this.creator&&(this.creator.destroy(),this.creator=null),this.store&&(this.store=null)}}document.addEventListener("DOMContentLoaded",(function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbSelector=new e)}))}))})(); |
| | |
| | | (()=>{class e{constructor(){this.a11y=window.jvbA11y,this.queue=window.jvbQueue,this.error=window.jvbError,this.templates=window.jvbTemplates,this.subscribers=new Set,this.initStores(),this.initWorker(),this.fields=new Map,this.uploads=new Map,this.groups=new Map,this.selected=new Map,this.selectionHandlers=new Map,this.sortables=new Map,this.changes=new Map,this.previewUrls=new Set,this.initElements(),this.initListeners(),this.defineTemplates()}defineTemplates(){const e=this.templates,t=this;e.define("uploadItem",{refs:{select:'[name="select-item"]',featured:'[name="featured"]',img:"img",video:"video",file:"label > span",details:"details",alt:'[name="image-alt-text"]',title:'[name="image-title"]',description:'[name="image-caption"]'},manyRefs:{inputs:"input, select, textarea"},setup({el:e,refs:s,manyRefs:i,data:r}){let a,o,l,d=!1;switch(Object.hasOwn(r,"file")?(e.dataset.uploadId=r.uploadId,a=t.getSubtypeFromMime(r.file.type)||"image",o="document"!==a&&t.createPreviewUrl(r.file),d=o,l=r.file.name||""):(e.dataset.id=r.id,a=t.getSubtypeFromURL(r.medium??r.src),o=r.medium??r.src,l=r["image-alt-text"]??""),e.dataset.subtype=a,s.featured&&(s.featured.value=r.uploadId),a){case"image":s.img&&(s.img.src=o,s.img.alt=l,d&&(s.img.dataset.previewUrl=d)),s.video&&s.video.remove(),s.file&&s.file.remove();break;case"video":s.video&&(s.video.src=o,s.video.alt=l,d&&(s.video.dataset.previewUrl=d)),s.img&&s.img.remove(),s.file&&s.file.remove();break;case"document":if(s.preview){let e=r.file.name.split(".").pop()?.toLowerCase()??"",t={pdf:"file-pdf",csv:"file-csv",doc:"file-doc",docx:"file-doc",txt:"file-txt",xls:"file-xls",xlsx:"file-xls"},i=window.getIcon(t[e]??"file");s.preview.innerText=r.file.name??r.title,s.preview.prepend(i)}s.img&&s.img.remove(),s.video&&s.video.remove()}if(s.details&&(Object.hasOwn(r.field.config,"showMeta")&&!r.field.config.showMeta?s.details.remove():(Object.hasOwn(r,"id")?s.details.dataset.attachmentId=r.id:Object.hasOwn(r,"uploadId")&&(s.details.dataset.uploadId=r.uploadId),s.details.setAttribute("data-ignore",""),"image"!==a&&s.alt?s.alt.closest(".field")?.remove():Object.hasOwn(r,"image-alt-text")&&s.alt&&(s.alt.value=r["image-alt-text"]),(Object.hasOwn(r,"title")||Object.hasOwn(r,"file"))&&s.title&&(s.title.value=r.title||r.file.name),Object.hasOwn(r,"image-caption")&&s.description&&(s.description.value=r["image-caption"]))),e.draggable="single"!==e.dataset.mode,i.inputs)for(let t of i.inputs){let s=t.closest("[data-field]")??e;window.prefixInput(t,`${r.id??r.uploadId}-`,s)}}}),e.define("imageGroup",{refs:{selectAll:"[data-select-all]",fields:".fields",details:"details",grid:".item-grid"},setup({el:t,refs:s,manyRefs:i,data:r}){if(t.dataset.groupId=r.groupId,s.selectAll){let e=s.selectAll.closest(".field");window.prefixInput(s.selectAll,`select-all-${r.groupId}`,e,!0)}let a=e.create("groupMetadata",{groupId:r.groupId});a?s.fields.append(a):s.details.remove(),s.grid&&(s.grid.dataset.groupId=r.groupId)}}),e.define("groupMetadata",{manyRefs:{inputs:"input,textarea,select"},setup({el:e,refs:t,manyRefs:s,data:i}){t.inputs&&t.inputs.forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${i.groupId}-`,t)}))}}),e.define("restoreNotification",{refs:{details:".details",wrap:".wrap"},setup({el:t,refs:s,manyRefs:i,data:r}){if(s.details){let e=r.bySource.size>1?` across ${r.bySource.size} pages`:"",t=r.pendingUploads.length>1?"uploads":"upload";s.details.textContent=`${r.pendingUploads.length} ${t} can be recovered${e}`}if(!s.wrap)return void console.warn("No wrap element in template");let a=1;for(const[t,i]of r.bySource){let r={index:a,isCurrent:t===window.location.href,src:t,uploads:i};s.wrap.append(e.create("restoreField",r)),a++}}}),e.define("restoreField",{refs:{h3:"h3",a:"h3 a",grid:".item-grid"},async setup({el:e,refs:s,manyRefs:i,data:r}){let a=t.registerField(e,!1,!1,`recovery_${r.index}`);r.isCurrent?(e.open=!0,s.a?.remove(),s.h3&&(s.h3.textContent="From this page:")):s.a&&(s.a.href=r.src,s.a.title="Navigate to page and restore",s.a.textContent=r.src);let o=[...new Set(r.uploads.map((e=>e.group??"preview")))];for(let e of o){let i="preview"===e||t.stores.groups.get(e);if(!i)continue;let o=await t.createGroupElement(e,a),l=o.querySelector(".item-grid"),d=r.uploads.filter((t=>t.group===("preview"===e)?null:e));for(const[e,t]of Object.entries(i.fields??{})){let s=o.querySelector(`input[name*="${e}"]`);s&&(s.value=t)}for(let e of d){let s=await t.createUpload(e.id,t.formatFile(e),a);l.append(s)}s.grid.append(o)}}})}initStores(){const{uploads:e,groups:t}=window.jvbStore.register("uploads",[{storeName:"uploads",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"status",keyPath:"status"},{name:"group",keyPath:"group"},{name:"src",keyPath:"src"}]},{storeName:"groups",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"src",keyPath:"src"}]}]);this.stores={uploads:e,groups:t,ready:[]},this.stores.uploads.subscribe(this.handleStores.bind(this,"uploads")),this.stores.groups.subscribe(this.handleStores.bind(this,"groups")),this.queue.subscribe(((e,t)=>{if(("operation-status"===e||"cancel-operation"===e)&&["image_upload","video_upload","document_upload"].includes(t.type)){let s=(t.data instanceof FormData?this.stores.uploads.formDataToObject(t.data):t.data).upload_ids;if(!s||0===s.length)return;if("cancel-operation"===e)return this.handleOperationCancelled(s);this.setBulkUpload(s,"status",t.status).then((()=>{})),"completed"===t.status&&s.forEach((e=>{this.removeUpload(e).then((()=>{}))}))}}))}storesReady(){return 2===this.stores.ready.length}handleStores(e,t){"data-ready"===t&&(this.stores.ready.push(e),this.storesReady()&&this.checkRecovery().then((()=>{})))}initWorker(){this.worker=null,this.workerState={worker:null,tasks:new Map,restart:{count:0,max:3},settings:{timeout:3e3,maxConcurrent:3,restartAfterTimeout:!0}}}initElements(){this.selectors={fields:{field:"[data-upload-field]",input:'input[type="file"]',dropZone:".file-upload-container",preview:".preview-wrap",grid:".item-grid.preview",progress:{progress:".file-upload-container .progress",fill:".file-upload-container .progress .fill",details:".file-upload-container .progress .details",icon:".file-upload-container .progress .icon"},selectAll:"[data-select-all]",actions:".selection-actions",count:".selected .info",hidden:'input[type="hidden"]'},groups:{container:".group-display",grid:".item-grid.groups",empty:".empty-group",header:".sidebar .header"},group:{item:".upload-group",actions:".selection-actions",selectAll:'[name="select-all-group"]',count:".group-header .info",fields:"details .fields",grid:".item-grid.group",total:".group-content .group-count"},items:{item:".item.upload",checkbox:'[name="select-item"]',featured:'[name="featured"]',image:"img",details:"details",progress:{progress:".progress",fill:".fill",details:".details",icon:".icon"}}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.dragEnterHandler=this.handleDragEnter.bind(this),this.dragLeaveHandler=this.handleDragLeave.bind(this),this.dragOverHandler=this.handleDragOver.bind(this),this.dropHandler=this.handleDrop.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("dragenter",this.dragEnterHandler),document.addEventListener("dragleave",this.dragLeaveHandler),document.addEventListener("dragover",this.dragOverHandler),document.addEventListener("drop",this.dropHandler),window.addEventListener("beforeunload",(()=>{this.cleanupAllPreviewUrls()}))}async setUpload(e,t){const s={...{id:e,attachment:null,group:null,field:null,src:window.location.href,blob:null,status:"local_processing",operationId:null,fields:{}},...t};return Object.preventExtensions(s),await this.stores.uploads.save(s),s}createPreviewUrl(e){const t=URL.createObjectURL(e);return this.previewUrls.add(t),t}revokePreviewUrl(e){e?.startsWith("blob:")&&(URL.revokeObjectURL(e),this.previewUrls.delete(e))}formatFile(e){return e.blob?new File([e.blob],e.fields.originalName||"file",{type:e.fields.type||e.blob.type,lastModified:e.fields.lastModified||Date.now()}):null}handleClick(e){let t=window.targetCheck(e,this.selectors.fields.dropZone);t&&!e.target.matches("input, button, a")&&t.querySelector(this.selectors.fields.input)?.click();const s=window.targetCheck(e,"[data-action]");s&&this.handleAction(s)}handleAction(e){const t=e.dataset.action,s=this.getFieldIdFromElement(e);switch(t){case"add-to-group":this.handleAddToGroup(s).then((()=>{}));break;case"delete-group":this.handleDeleteGroup(e);break;case"delete-upload":case"remove-from-group":this.handleRemoveItem(e).then((()=>{}));break;case"upload":this.queueUploads("uploads/groups",s).then((()=>{}));break;case"restore":this.handleRestoreSelected().then((()=>{}));break;case"restore-all":this.handleRestoreAll().then((()=>{}));break;case"clear-cache":this.handleClearCache().then((()=>{}))}}handleChange(e){let t=this.getFieldIdFromElement(e.target);if(t)if(e.target.matches(this.selectors.fields.input)){const s=Array.from(e.target.files);s.length>0&&this.processFiles(t,s).then((()=>{}))}else e.target.matches(this.selectors.items.checkbox)||e.target.matches(this.selectors.items.featured)||e.target.matches('[name*="select-"]')||("post_group"===this.fields.get(t).config.destination?this.handleGroupMetaChange(e.target):this.queueUploadMeta(e));else{e.target.closest("[data-upload-id], [data-attachment-id]")&&this.queueUploadMeta(e)}}handleGroupMetaChange(e){const t=e.dataset.groupId;if(!t)return;const s=e.name;if(!s)return;const i=e.value,r=s.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");window.debouncer.schedule(`group-meta-${t}-${r}`,(async()=>{const e=this.stores.groups.get(t);e&&(e.fields||(e.fields={}),e.fields[r]=i,await this.setGroup(t,e))}),300)}handleDragEnter(e){if(!e.dataTransfer.types.includes("Files"))return;const t=e.target.closest(this.selectors.fields.dropZone);t&&(e.preventDefault(),t.classList.add("dragover"))}handleDragLeave(e){const t=e.target.closest(this.selectors.fields.dropZone);t&&!t.contains(e.relatedTarget)&&t.classList.remove("dragover")}handleDragOver(e){if(!e.dataTransfer.types.includes("Files"))return;e.target.closest(this.selectors.fields.dropZone)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")}handleDrop(e){const t=e.target.closest(this.selectors.fields.dropZone);if(!t)return;e.preventDefault(),t.classList.remove("dragover"),t.classList.add("uploading");const s=Array.from(e.dataTransfer.files);if(0===s.length)return;const i=this.getFieldIdFromElement(t);i&&(this.processFiles(i,s).then((()=>{this.updateHandlerItems(i)})),this.a11y.announce(`${s.length} file(s) dropped for upload`))}async queueUploads(e,t){let s=new FormData;const i=this.fields.get(t);if(!i)return;let r=this.stores.uploads.filterByIndex({field:t});if(0===r.length)return;const[a,o]=["uploads"===e,"uploads/groups"===e];let l,d,n,u,p;s.append("fieldId",i.id),s.append("content",i.config.content),a&&(s.append("mode",i.config.mode),s.append("field_name",i.config.name),s.append("fieldId",i.id),s.append("field_type",i.config.type),s.append("subtype",i.config.subtype),s.append("item_id",i.config.itemID),s.append("destination",i.config.destination)),o?({posts:l,uploadMap:d,files:n}=this.collectGroups(t)):a&&({uploadMap:d,files:n}=this.collectUploads(t)),o&&s.append("posts",JSON.stringify(l)),n.forEach((e=>{s.append("files[]",e)})),s.append("upload_ids",JSON.stringify(d)),a?(u=`Uploading ${r.length} file${r.length>1?"s":""} to server...`,p=`Uploading ${r.length} file${r.length>1?"s":""}...`):o&&(u=`Creating ${l.length} ${i.config.content}${l.length>1?"s":""} from uploads...`,p=`Creating ${l.length} post${l.length>1?"s":""}...`),await this.setBulkUpload(r,"status","queued");let c=this.sendToQueue(e,s,u,p);if("uploads/groups"===e){let e=i.element.closest("details");e&&(e.open=!1)}return c?(i.operationId=c,await this.setBulkUpload(r,"operationId",c),await this.setBulkUpload(r,"status","uploading"),await this.setBulkGroup(t,"operationId",c),this.fields.set(i.id,i),this.notify("sent-to-queue",{field:i,operation:c})):await this.setBulkUpload(r,"status","failed"),c}async sendToQueue(e,t,s="",i="",r=!1){""===i&&(i=s);const a={endpoint:e,method:"POST",data:t,title:s,popup:i,canMerge:r,sendNow:"uploads/groups"===e,headers:{action_nonce:window.auth.getNonce("dash")},append:"_upload"};try{return await this.queue.addToQueue(a)}catch(e){return this.error.log(e,{component:"UploadManager",action:"sentToQueue"}),!1}}collectGroups(e){let t=this.stores.uploads.filterByIndex({field:e}),s=[],i=[],r=[];const a=this.stores.groups.filterByIndex({field:e}).filter((e=>{const t=this.getGroupUploadsInOrder(e);return t.length>0&&t.some((e=>this.formatFile(e)))}));for(const e of a){const t=this.groups.get(e.id)?.element,a={images:[],fields:this.collectGroupFieldsFromDOM(t,e.id)},o=this.getGroupUploadsInOrder(e);for(const t of o){const s=this.formatFile(t);if(s){r.push(s);const o={upload_id:t.id,index:i.length},l=this.uploads.get(t.id),d=l?.element?.querySelector(`input[name="${e.id}_featured"]`);d?.checked&&(a.fields.featured=t.id),a.images.push(o),i.push(t.id)}}a.images.length>0&&s.push(a)}const o=t.filter((e=>!e.group));for(const e of o){const t={images:[],fields:{}},a=this.formatFile(e);if(a){r.push(a);const s={upload_id:e.id,index:i.length};t.images.push(s),i.push(e.id)}t.images.length>0&&s.push(t)}return{posts:s,uploadMap:i,files:r}}getGroupUploadsInOrder(e){return e.uploads&&0!==e.uploads.length?e.uploads.map((e=>this.stores.uploads.get(e))).filter(Boolean):[]}collectGroupFieldsFromDOM(e,t){if(!e)return{};const s={};return e.querySelectorAll("input, textarea, select").forEach((e=>{const i=e.name.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");["featured","select-all"].some((e=>i.includes(e)))||e.value&&(s[i]=e.value)})),s}collectUploads(e){let t=this.stores.uploads.filterByIndex({field:e});if(0===t.length)return;let s=[],i=[];for(const e of t){const t=this.formatFile(e);t&&(i.push(t),s.push(e.id))}return{uploadMap:s,files:i}}queueUploadMeta(e){let t=e.target.closest("[data-attachment-id]")?.dataset.attachmentId,s=!1;if(!t&&(t=e.target.closest("[data-upload-id]")?.dataset.uploadId,s=!0,!t))return;if(!this.changes.has(t)){let e={};s?e.uploadId=t:e.attachmentId=t,this.changes.set(t,e)}let i=e.target.closest("[data-field]").dataset.field;this.changes.get(t)[i]=e.target.value,this.scheduleSave()}scheduleSave(){window.debouncer.schedule("upload-meta",(async()=>{if(this.changes.size>0){let e={};for(let[t,s]of this.changes.entries())console.log(t,s),e[t]=s;let t={user:window.auth.getUser(),items:e};await this.sendToQueue("uploads/meta",t,"Uploading Meta","Uploading Meta",!0),this.changes.clear()}}),2e3)}scanFields(e,t=!0,s=!0){e.querySelectorAll(this.selectors.fields.field).forEach((e=>this.registerField(e,t,s)))}registerField(e,t=!0,s=!0,i=null){const r={element:e,id:i||this.determineFieldId(e),config:this.extractFieldConfig(e,t,s),uploads:new Set,operationId:null,groups:[],ui:window.uiFromSelectors(this.selectors.fields,e),groupUI:window.uiFromSelectors(this.selectors.groups,e)};return this.fields.set(r.id,r),e.dataset.uploader=r.id,this.getSelectionHandler(r.id),"single"!==r.config.type&&this.initSortable(r.id),r.id}extractFieldConfig(e,t,s){return{autoUpload:t,showMeta:s,destination:e.dataset.destination||"meta",content:this.extractFieldContent(e),mode:e.dataset.mode||"direct",type:e.dataset.type||"single",name:e.dataset.field,itemID:this.extractFieldItemId(e)??0,maxFiles:parseInt(e.dataset.maxFiles)??25,subType:e.dataset.subtype??"image"}}extractFieldContent(e){return e.dataset.content||e.closest("dialog")?.dataset.content||e.closest("form")?.dataset.save||null}extractFieldItemId(e){return e.dataset.itemId||e.closest("dialog")?.dataset.itemId||null}determineFieldId(e){let t=this.extractFieldContent(e);t=null===t?"":t+"_";let s=this.extractFieldItemId(e);s=null===s?"":s+"_";return`${t}${s}${e.dataset.field||""}`}getFieldIdFromElement(e){const t=e.closest(this.selectors.fields.field);return t?.dataset.uploader||null}updateFieldProgress(e,t,s,i){const r=this.fields.get(e);r&&window.showProgress(r.ui.progress,t,s,i)}getWorker(){return this.workerState.worker||"undefined"==typeof OffscreenCanvas||(this.workerState.worker=new Worker("worker.js"),this.workerState.worker.onmessage=e=>this.handleWorkerMessage(e),this.workerState.worker.onerror=e=>this.handleWorkerError(e)),this.workerState.worker}handleWorkerMessage(e){const{id:t,blob:s}=e.data,i=this.workerState.tasks.get(t);i&&(clearTimeout(i.timeoutId),i.resolve(s),this.workerState.tasks.delete(t))}handleWorkerError(e){this.workerState.tasks.forEach((t=>{clearTimeout(t.timeoutId),t.reject(e)})),this.workerState.tasks.clear(),this.restartWorker()}restartWorker(){this.workerState.worker&&(this.workerState.worker.terminate(),this.workerState.worker=null),this.workerState.restart.count++}async processImages(e,t=2200,s=2200){const i=[],r=[...e],a=this.workerState.settings.maxConcurrent,o=async()=>{for(;r.length>0;){const e=r.shift(),a=await this.processImage(e.file,t,s);i.push({uploadId:e.uploadId,blob:a})}};return await Promise.all(Array.from({length:Math.min(a,e.length)},(()=>o()))),i}async processImage(e,t=2200,s=2200,i=3e3){if("undefined"==typeof OffscreenCanvas)return this.resizeImage(e,t,s);try{return await this.withTimeout(this.workerImage(e,t,s),i)}catch(i){return this.resizeImage(e,t,s)}}withTimeout(e,t){return Promise.race([e,new Promise(((e,s)=>setTimeout((()=>s(new Error("Timeout"))),t)))])}async workerImage(e,t=2200,s=2200){const{settings:i,restart:r}=this.workerState;if(r.count>=r.max)throw new Error("Worker max restarts exceeded");const a=await createImageBitmap(e);let{width:o,height:l}=a;if(o>t||l>s){const e=Math.min(t/o,s/l);o=Math.round(o*e),l=Math.round(l*e)}const d=this.getWorker(),n=crypto.randomUUID();return new Promise(((t,s)=>{const r=setTimeout((()=>{this.workerState.tasks.delete(n),i.restartAfterTimeout&&this.restartWorker(),s(new Error("Timeout"))}),i.timeout);this.workerState.tasks.set(n,{resolve:t,reject:s,timeoutId:r}),d.postMessage({id:n,imageBitmap:a,width:o,height:l,type:e.type,quality:.9},[a])}))}resizeImage(e,t,s){return new Promise((i=>{const r=new Image;r.onload=()=>{URL.revokeObjectURL(r.src);let{width:a,height:o}=r;if(a>t||o>s){const e=Math.min(t/a,s/o);a=Math.round(a*e),o=Math.round(o*e)}const l=document.createElement("canvas");l.width=a,l.height=o,l.getContext("2d").drawImage(r,0,0,a,o),l.toBlob(i,e.type,.9)},r.src=URL.createObjectURL(e)}))}async processFiles(e,t){let s=this.fields.get(e);if(!s)return;s.groupUI.container&&(s.groupUI.container.hidden=!1);const i=t.length;let r=0;this.updateFieldProgress(e,0,i,"Processing files...");const a=await Promise.all(t.map((async t=>{const s=window.generateID("upload"),i=await this.setUpload(s,{id:s,field:e,status:"local_processing",fields:{originalName:t.name,originalSize:t.size,type:t.type,lastModified:t.lastModified}}),r=await this.createUpload(s,t,e);return this.uploads.set(s,{element:r,ui:window.uiFromSelectors(this.selectors.items,r)}),await this.addToGroup(s,null),{uploadId:s,upload:i,file:t}}))),o=a.filter((e=>e.file.type.startsWith("image/"))),l=a.filter((e=>!e.file.type.startsWith("image/"))),d=await this.processImages(o.map((e=>({file:e.file,uploadId:e.uploadId}))));for(const{uploadId:t,blob:s}of d){const a=o.find((e=>e.uploadId===t));a&&(a.upload.blob=s,a.upload.fields.size=s.size,a.upload.status="queued",await this.setUpload(t,a.upload),r++,this.updateFieldProgress(e,r,i,"Processing files..."))}for(const{uploadId:t,upload:s,file:a}of l)s.blob=a,s.status="queued",await this.setUpload(t,s),r++,this.updateFieldProgress(e,r,i,"Processing files...");this.maybeLockUploads(e),s.config.autoUpload&&"post_group"!==s.config.destination&&await this.queueUploads("uploads",e)}async checkRecovery(){const e=this.stores.uploads.filterByIndex({status:["local_processing","queued","uploading"]}),t=Array.from(this.stores.groups.data.values());for(const e of t){this.stores.uploads.filterByIndex({group:e.id}).length>0||await this.stores.groups.delete(e.id)}if(0===e.length)return;const s=new Map;e.forEach((e=>{const t=e.src||"unknown";s.has(t)||s.set(t,[]),s.get(t).push(e)}));let i={bySource:s,pendingUploads:e};document.body.append(this.templates.create("restoreNotification",i));let r=document.querySelector("dialog.restore-uploads");this.restoreModal=new window.jvbModal(r),this.restoreSelection=new window.jvbHandleSelection(r,{wrapper:{wrapper:".restore-field",id:"selection"},items:".item-grid.restore",selectAll:{bulkControls:".selection-actions",checkbox:"#select-all-restore",count:".selection-count"}}),this.restoreModal.handleOpen()}async handleRestoreSelected(){if(!this.restoreSelection)return;let e=Array.from(this.restoreSelection.selectedItems);0!==e.length&&await this.restoreSelectedUploads(e)}async handleRestoreAll(){if(!this.restoreModal)return;const e=Array.from(this.restoreModal.modal.querySelectorAll(".item.upload")).map((e=>e.dataset.uploadId));await this.restoreSelectedUploads(e)}async restoreSelectedUploads(e){let t=window.location.href,s=Array.from(this.stores.uploads.data.values()).filter((s=>e.includes(s.id)&&s.src===t)),i=[...new Set(s.map((e=>e.group)))].filter(Boolean),r=s[0].field;if(!document.querySelector(`[data-uploader="${r}"]`))return void console.log("No field found for "+r);let a=this.fields.get(r);a.groupUI.container&&(a.groupUI.container.hidden=!1);let o=[];for(let e of i){let t=this.stores.groups.get(e);await this.createGroup(r,e);let i=this.groups.get(e),a=s.filter((t=>t.group===e));if(t&&this.groups.has(e)){let e=t.fields;for(const[t,s]of Object.entries(e)){let e=i.element.querySelector(`input[name*="${t}"]`);e&&(e.value=s)}}else e=null;for(let t of a){let s=await this.createUpload(t.id,this.formatFile(t),r);this.uploads.set(t.id,{element:s,ui:window.uiFromSelectors(this.selectors.items,s)}),await this.addToGroup(t.id,e),o.push(t.id)}}let l=s.filter((e=>!o.includes(e.id)));for(let e of l){let t=await this.createUpload(e.id,this.formatFile(e),r);this.uploads.set(e.id,{element:t,ui:window.uiFromSelectors(this.selectors.items,t)}),await this.addToGroup(e.id,null)}this.cleanupRestore()}cleanupRestore(){this.restoreModal.handleClose(),this.restoreSelection.destroy(),this.restoreSelection=null,this.restoreModal.destroy(),this.restoreModal.modal.remove(),this.restoreModal=null}getStatusText(e){return{received:"Image Received",local_processing:"Processing Image...",queued:"Waiting to upload...",uploading:"Uploading to Server",pending:"Successfully sent to server. In line for further processing.",processing:"Processing on server...",completed:"Upload complete!",failed:"Upload failed (will retry)",failed_permanent:"Upload failed permanently"}[e]||e}getStatusProgress(e){return{local_processing:28,queued:50,uploading:66,pending:75,processing:89,completed:100}[e]??0}async createUpload(e,t,s){let i=this.fields.get(s);if(!i)return null;let r={uploadId:e,file:t,field:i};return this.templates.create("uploadItem",r)}getSubtypeFromURL(e){const t=e.split("?")[0].toLowerCase();return[".webp",".jpg",".jpeg",".png",".gif",".svg"].some((e=>t.endsWith(e)))?"image":[".mp4",".ogg",".mov",".webm",".avi"].some((e=>t.endsWith(e)))?"video":"document"}getSubtypeFromMime(e){return e.startsWith("image/")?"image":e.startsWith("video/")?"video":"document"}async handleRemoveItem(e){const t=e.closest(this.selectors.items.item);if(!t)return;const s=t.dataset.uploadId;confirm("Remove this item?")&&(await this.removeUpload(s),this.a11y.announce("Item removed"))}async setBulkUpload(e,t,s){const i=Array.from(e).map((async e=>{if("string"==typeof e&&(e=await this.stores.uploads.get(e)),e)return"status"===t&&await this.setUploadStatus(e,s),e[t]=s,this.stores.uploads.save(e)}));await Promise.all(i)}async setUploadStatus(e,t){"string"==typeof e&&(e=await this.stores.uploads.get(e)),e&&e.progress&&window.showProgress(e.progress,this.getStatusProgress(t),100,this.getStatusText(t),this.queue.icons[t]??"")}async removeUpload(e){let t=this.stores.uploads.get(e);if(!t)return;if(t.group){let s=this.stores.groups.get(t.group);s.uploads=s.uploads.filter((t=>t!==e)),0===s.uploads.length?await this.removeGroup(s.id,!1):await this.stores.groups.save(s)}await this.clearUpload(e),this.maybeLockUploads(t.field);let s=this.selectionHandlers.get(t.field);s&&s.deselect(e),this.a11y.announce("Upload removed")}async clearUpload(e){const t=this.uploads.get(e);if(t&&(this.revokePreviewUrl(t.preview),t.element)){const e=t.element.dataset.previewUrl;this.revokePreviewUrl(e),t.element.remove()}this.uploads.delete(e),await this.stores.uploads.delete(e)}async handleAddToGroup(e){const t=this.selected.get(e);if(!t||0===t.size)return;let s=await this.createGroup(e);s&&(await Promise.all(Array.from(t).map((e=>this.addToGroup(e,s)))),this.selectionHandlers.get(e)?.clearSelection(),this.a11y.announce(`Created group with ${t.size} items`))}async createGroup(e,t=null){let s=this.fields.get(e);if(!s)return;t||(t=window.generateID("group"));const i=this.createGroupElement(t,e);if(!i)return null;const r=s.groupUI.empty;r?.nextSibling?s.groupUI.grid.insertBefore(i,r.nextSibling):s.groupUI.grid.append(i);const a=i.querySelector(".item-grid");a&&(a.dataset.groupId=t,this.createSortable(e,a,t));let o=this.stores.groups.data.has(t)?this.stores.groups.data.get(t):{};return await this.setGroup(t,{...o,id:t,field:e}),t}createGroupElement(e,t=null){let s={groupId:e,fieldId:t},i=this.templates.create("imageGroup",s);return this.groups.set(e,{element:i,ui:window.uiFromSelectors(this.selectors.group,i)}),this.getSelectionHandler(t)?.addWrapper(i),i}async setGroup(e,t){const s={...{id:e,src:window.location.href,uploads:[],operationId:null,field:null,fields:{}},...t};Object.preventExtensions(s),await this.stores.groups.save(s)}async setBulkGroup(e,t,s){let i=this.stores.groups.filterByIndex({field:e});if(0===i.length)return;let r=i.map((e=>{e[t]=s,this.stores.groups.save(e)}));await Promise.all(r)}async addToGroup(e,t=null){const s=this.stores.uploads.get(e),i=this.uploads.get(e);if(!s||!i)return;const r=this.fields.get(s.field);if(!r)return;if(null!==i.element?.parentElement&&(!t&&null===s.group||t===s.group))return void this.handleReorder(s.field,t);if(s.group){const t=this.stores.groups.get(s.group);t&&(t.uploads=t.uploads.filter((t=>t!==e)),0===t.uploads.length?await this.removeGroup(t.id,!1):await this.stores.groups.save(t))}i.ui.checkbox&&(i.ui.checkbox.checked=!1);const a=this.selectionHandlers.get(s.field);if(a&&a.isSelected(e)&&a.deselect(e),this.selected.get(s.field)?.has(e)&&this.selected.get(s.field).delete(e),i.ui.featured&&(i.ui.featured.hidden=!t),t){i.ui.featured&&(i.ui.featured.name=`${t}_featured`);let r=this.stores.groups.get(t);r&&(r.uploads.push(e),s.group=t,await this.stores.groups.save(r))}else s.group=null;let o=t?this.groups.get(t)?.ui.grid:r.ui.grid;o&&(o.append(i.element),t&&await this.handleReorder(s.field,t)),await this.stores.uploads.save(s)}handleDeleteGroup(e){const t=e.closest(this.selectors.group.item);if(!t)return;let s=t.dataset.groupId;if(!confirm("Delete this group? Items will be moved back to the upload area."))return;let i=this.stores.uploads.filterByIndex({group:s});Promise.all(i.map((e=>this.addToGroup(e.id,null)))).then((()=>{this.removeGroup(s,!1).then((()=>{})),this.a11y.announce("Group deleted. Items returned to upload area")}))}async removeGroup(e,t=!0){let s=this.groups.get(e),i=this.stores.groups.get(e);if(!i)return;let r=!0;t&&i.uploads.length>0&&(r=window.confirm("Keep uploads in this group?")),await Promise.all(i.uploads.map((e=>r?this.addToGroup(e,null):this.removeUpload(e))));if(this.fields.get(i.field)){const t=this.getGroupKey(i.field,e),r=this.selectionHandlers.get(t);r?.destroy&&r.destroy(),this.selectionHandlers.get(i.field)?.removeWrapper(s.element);const a=this.sortables.get(t);a?.destroy&&a.destroy(),this.sortables.delete(t)}s?.element&&s.element.remove(),this.groups.delete(e),await this.stores.groups.delete(e),this.a11y.announce("Group removed")}maybeLockUploads(e){let t=this.fields.get(e);if(!t||!t.ui.dropZone)return;let s=this.stores.uploads.filterByIndex({field:e}).length,i=t.config.maxFiles??25;t.ui.dropZone.hidden=s>=i}async handleOperationCancelled(e){0!==e.length&&e.forEach((e=>{this.removeUpload(e)}))}getGroupKey(e,t=null){return t?`${e}_${t}`:`${e}`}getSelectionHandler(e){let t=this.getGroupKey(e);if(!this.selectionHandlers.has(t)){let s=this.fields.get(e);if(!s)return;if("post_group"!==s.config.destination)return;let i=new window.jvbHandleSelection(s.element,{selectAll:{checkbox:this.selectors.fields.selectAll,count:this.selectors.fields.count,bulkControls:this.selectors.fields.actions},item:{item:this.selectors.items.item,checkbox:this.selectors.items.checkbox,idAttribute:"uploadId"},wrapper:{wrapper:".preview-wrap, .upload-group",id:"groupId"}});i.subscribe(((t,s)=>{this.selected.set(e,s.selectedItems)})),this.selectionHandlers.set(t,i)}return this.selectionHandlers.get(t)}updateHandlerItems(e){let t=this.getSelectionHandler(e);t&&t.collectItems()}initSortable(e){if(!window.Sortable)return;const t=this.fields.get(e);t&&(!Sortable._multiDragMounted&&Sortable.MultiDrag&&(Sortable.mount(new Sortable.MultiDrag),Sortable._multiDragMounted=!0),this.createSortable(e,t.ui.grid,null),this.initEmptyGroupDropZone(e))}createSortable(e,t,s){if(!t)return null;const i=this.getGroupKey(e,s);if(this.sortables.has(i))return this.sortables.get(i);const r=new Sortable(t,{animation:150,draggable:".item",multiDrag:!0,selectedClass:"selected",avoidImplicitDeselect:!0,group:{name:e,pull:!0,put:!0},dragClass:"dragging",ignore:".empty-group",onStart:t=>{const s=t.item,i=s?.dataset.uploadId,r=this.selected.get(e);if(i&&(!r||!r.has(i))){const t=this.selectionHandlers.get(e);t&&t.select(i)}},onEnd:t=>this.sortableDrop(t,e)});return this.sortables.set(i,r),r}initEmptyGroupDropZone(e){const t=this.fields.get(e),s=t?.groupUI?.empty;s&&(s.addEventListener("dragover",(e=>{e.preventDefault(),e.stopPropagation(),e.dataTransfer.dropEffect="move",s.classList.add("drag-over")})),s.addEventListener("dragleave",(e=>{s.contains(e.relatedTarget)||s.classList.remove("drag-over")})),s.addEventListener("drop",(async t=>{t.preventDefault(),t.stopPropagation(),s.classList.remove("drag-over");const i=this.selected.get(e);if(!i||0===i.size)return;const r=await this.createGroup(e);r&&(await Promise.all(Array.from(i).map((e=>this.addToGroup(e,r)))),this.selectionHandlers.get(e)?.clearSelection())})))}async sortableDrop(e,t){const s=e.to,i=(e.items?.length>0?Array.from(e.items):[e.item]).map((e=>e.dataset.uploadId)).filter(Boolean);if(0===i.length)return;const r=s.dataset.groupId||null;for(const e of i)await this.addToGroup(e,r);await this.handleReorder(t,r),this.selectionHandlers.get(t)?.clearSelection()}handleReorder(e,t=null){let s=t?this.groups.get(t)?.ui.grid:this.fields.get(e)?.ui.grid;if(!s)return void console.log("Couldn't Reorder items...");let i=Array.from(s.children).filter((e=>e.matches(this.selectors.items.item)&&!e.classList.contains("ghost"))).map((e=>e.dataset.uploadId)).filter((e=>e));if(t){let e=this.stores.groups.get(t);e&&(e.uploads=i,this.stores.groups.save(e).then((()=>{})))}else{let t=this.fields.get(e)?.ui.hidden;t&&(t.value=i.join(","))}this.a11y.announce("Items reordered")}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){this.subscribers.clear(),this.previewUrls.forEach((e=>{this.revokePreviewUrl(e)})),this.previewUrls.clear()}cleanupAllPreviewUrls(){this.previewUrls.forEach((e=>this.revokePreviewUrl(e))),this.previewUrls.clear()}async handleClearCache(){const e=window.location.href,t=this.stores.uploads.filterByIndex({src:e}),s=this.stores.groups.filterByIndex({src:e});await Promise.all([...t.map((e=>this.clearUpload(e.id))),...s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id))))]),this.restoreModal&&this.cleanupRestore(),this.a11y.announce("Cache cleared for this page")}async getFilesForForm(e){const t=e.querySelectorAll(this.selectors.fields.field),s=[];for(const e of t){const t=this.determineFieldId(e),i=e.dataset.field,r=this.stores.uploads.filterByIndex({field:t});for(const e of r){const t=this.formatFile(e);t&&s.push({file:t,fieldName:i,uploadId:e.id,meta:e.fields||{}})}}return s}async clearFieldFromStores(e){const t=this.stores.uploads.filterByIndex({field:e}),s=this.stores.groups.filterByIndex({field:e});await Promise.all(t.map((e=>this.clearUpload(e.id)))),await Promise.all(s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id)))))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbUploads=new e)}))}))})(); |
| | | (()=>{class e{constructor(){this.a11y=window.jvbA11y,this.queue=window.jvbQueue,this.error=window.jvbError,this.templates=window.jvbTemplates,this.subscribers=new Set,this.initStores(),this.initWorker(),this.fields=new Map,this.uploads=new Map,this.groups=new Map,this.selected=new Map,this.selectionHandlers=new Map,this.sortables=new Map,this.changes=new Map,this.previewUrls=new Set,this.initElements(),this.initListeners(),this.defineTemplates()}defineTemplates(){const e=this.templates,t=this;e.define("uploadItem",{refs:{select:'[name="select-item"]',featured:'[name="featured"]',img:"img",video:"video",file:"label > span",details:"details",alt:'[name="image-alt-text"]',title:'[name="image-title"]',description:'[name="image-caption"]'},manyRefs:{inputs:"input, select, textarea"},setup({el:e,refs:s,manyRefs:i,data:r}){let a,o,l,d=!1;switch(Object.hasOwn(r,"file")?(e.dataset.uploadId=r.uploadId,a=t.getSubtypeFromMime(r.file.type)||"image",o="document"!==a&&t.createPreviewUrl(r.file),d=o,l=r.file.name||""):(e.dataset.id=r.id,a=t.getSubtypeFromURL(r.medium??r.src),o=r.medium??r.src,l=r["image-alt-text"]??""),e.dataset.subtype=a,s.featured&&(s.featured.value=r.uploadId),a){case"image":s.img&&(s.img.src=o,s.img.alt=l,d&&(s.img.dataset.previewUrl=d)),s.video&&s.video.remove(),s.file&&s.file.remove();break;case"video":s.video&&(s.video.src=o,s.video.alt=l,d&&(s.video.dataset.previewUrl=d)),s.img&&s.img.remove(),s.file&&s.file.remove();break;case"document":if(s.preview){let e=r.file.name.split(".").pop()?.toLowerCase()??"",t={pdf:"file-pdf",csv:"file-csv",doc:"file-doc",docx:"file-doc",txt:"file-txt",xls:"file-xls",xlsx:"file-xls"},i=window.getIcon(t[e]??"file");s.preview.innerText=r.file.name??r.title,s.preview.prepend(i)}s.img&&s.img.remove(),s.video&&s.video.remove()}if(s.details&&(Object.hasOwn(r,"field")&&Object.hasOwn(r.field,"config")&&Object.hasOwn(r.field.config,"showMeta")&&!r.field.config.showMeta?s.details.remove():(Object.hasOwn(r,"id")?s.details.dataset.attachmentId=r.id:Object.hasOwn(r,"uploadId")&&(s.details.dataset.uploadId=r.uploadId),s.details.setAttribute("data-ignore",""),"image"!==a&&s.alt?s.alt.closest(".field")?.remove():Object.hasOwn(r,"image-alt-text")&&s.alt&&(s.alt.value=r["image-alt-text"]),(Object.hasOwn(r,"title")||Object.hasOwn(r,"file"))&&s.title&&(s.title.value=r.title||r.file.name),Object.hasOwn(r,"image-caption")&&s.description&&(s.description.value=r["image-caption"]))),e.draggable="single"!==e.dataset.mode,i.inputs)for(let t of i.inputs){let s=t.closest("[data-field]")??e;window.prefixInput(t,`${r.id??r.uploadId}-`,s)}}}),e.define("imageGroup",{refs:{selectAll:"[data-select-all]",fields:".fields",details:"details",grid:".item-grid"},setup({el:t,refs:s,manyRefs:i,data:r}){if(t.dataset.groupId=r.groupId,s.selectAll){let e=s.selectAll.closest(".field");window.prefixInput(s.selectAll,`select-all-${r.groupId}`,e,!0)}let a=e.create("groupMetadata",{groupId:r.groupId});a?s.fields.append(a):s.details.remove(),s.grid&&(s.grid.dataset.groupId=r.groupId)}}),e.define("groupMetadata",{manyRefs:{inputs:"input,textarea,select"},setup({el:e,refs:t,manyRefs:s,data:i}){t.inputs&&t.inputs.forEach((e=>{let t=e.closest("[data-field]");window.prefixInput(e,`${i.groupId}-`,t)}))}}),e.define("restoreNotification",{refs:{details:".details",wrap:".wrap"},setup({el:t,refs:s,manyRefs:i,data:r}){if(s.details){let e=r.bySource.size>1?` across ${r.bySource.size} pages`:"",t=r.pendingUploads.length>1?"uploads":"upload";s.details.textContent=`${r.pendingUploads.length} ${t} can be recovered${e}`}if(!s.wrap)return void console.warn("No wrap element in template");let a=1;for(const[t,i]of r.bySource){let r={index:a,isCurrent:t===window.location.href,src:t,uploads:i};s.wrap.append(e.create("restoreField",r)),a++}}}),e.define("restoreField",{refs:{h3:"h3",a:"h3 a",grid:".item-grid"},async setup({el:e,refs:s,manyRefs:i,data:r}){let a=t.registerField(e,!1,!1,`recovery_${r.index}`);r.isCurrent?(e.open=!0,s.a?.remove(),s.h3&&(s.h3.textContent="From this page:")):s.a&&(s.a.href=r.src,s.a.title="Navigate to page and restore",s.a.textContent=r.src);let o=[...new Set(r.uploads.map((e=>e.group??"preview")))];for(let e of o){let i="preview"===e||t.stores.groups.get(e);if(!i)continue;let o=await t.createGroupElement(e,a),l=o.querySelector(".item-grid"),d=r.uploads.filter((t=>t.group===("preview"===e)?null:e));for(const[e,t]of Object.entries(i.fields??{})){let s=o.querySelector(`input[name*="${e}"]`);s&&(s.value=t)}for(let e of d){let s=await t.createUpload(e.id,t.formatFile(e),a);l.append(s)}s.grid.append(o)}}})}initStores(){const{uploads:e,groups:t}=window.jvbStore.register("uploads",[{storeName:"uploads",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"status",keyPath:"status"},{name:"group",keyPath:"group"},{name:"src",keyPath:"src"}]},{storeName:"groups",keyPath:"id",indexes:[{name:"field",keyPath:"field"},{name:"src",keyPath:"src"}]}]);this.stores={uploads:e,groups:t,ready:[]},this.stores.uploads.subscribe(this.handleStores.bind(this,"uploads")),this.stores.groups.subscribe(this.handleStores.bind(this,"groups")),this.queue.subscribe(((e,t)=>{if(("operation-status"===e||"cancel-operation"===e)&&["image_upload","video_upload","document_upload"].includes(t.type)){let s=(t.data instanceof FormData?this.stores.uploads.formDataToObject(t.data):t.data).upload_ids;if(!s||0===s.length)return;if("cancel-operation"===e)return this.handleOperationCancelled(s);this.setBulkUpload(s,"status",t.status).then((()=>{})),"completed"===t.status&&s.forEach((e=>{this.removeUpload(e).then((()=>{}))}))}}))}storesReady(){return 2===this.stores.ready.length}handleStores(e,t){"data-ready"===t&&(this.stores.ready.push(e),this.storesReady()&&this.checkRecovery().then((()=>{})))}initWorker(){this.worker=null,this.workerState={worker:null,tasks:new Map,restart:{count:0,max:3},settings:{timeout:3e3,maxConcurrent:3,restartAfterTimeout:!0}}}initElements(){this.selectors={fields:{field:"[data-upload-field]",input:'input[type="file"]',dropZone:".file-upload-container",preview:".preview-wrap",grid:".item-grid.preview",progress:{progress:".file-upload-container .progress",fill:".file-upload-container .progress .fill",details:".file-upload-container .progress .details",icon:".file-upload-container .progress .icon"},selectAll:"[data-select-all]",actions:".selection-actions",count:".selected .info",hidden:'input[type="hidden"]'},groups:{container:".group-display",grid:".item-grid.groups",empty:".empty-group",header:".sidebar .header"},group:{item:".upload-group",actions:".selection-actions",selectAll:'[name="select-all-group"]',count:".group-header .info",fields:"details .fields",grid:".item-grid.group",total:".group-content .group-count"},items:{item:".item.upload",checkbox:'[name="select-item"]',featured:'[name="featured"]',image:"img",details:"details",progress:{progress:".progress",fill:".fill",details:".details",icon:".icon"}}}}initListeners(){this.clickHandler=this.handleClick.bind(this),this.changeHandler=this.handleChange.bind(this),this.dragEnterHandler=this.handleDragEnter.bind(this),this.dragLeaveHandler=this.handleDragLeave.bind(this),this.dragOverHandler=this.handleDragOver.bind(this),this.dropHandler=this.handleDrop.bind(this),document.addEventListener("click",this.clickHandler),document.addEventListener("change",this.changeHandler),document.addEventListener("dragenter",this.dragEnterHandler),document.addEventListener("dragleave",this.dragLeaveHandler),document.addEventListener("dragover",this.dragOverHandler),document.addEventListener("drop",this.dropHandler),window.addEventListener("beforeunload",(()=>{this.cleanupAllPreviewUrls()}))}async setUpload(e,t){const s={...{id:e,attachment:null,group:null,field:null,src:window.location.href,blob:null,status:"local_processing",operationId:null,fields:{}},...t};return Object.preventExtensions(s),await this.stores.uploads.save(s),s}createPreviewUrl(e){const t=URL.createObjectURL(e);return this.previewUrls.add(t),t}revokePreviewUrl(e){e?.startsWith("blob:")&&(URL.revokeObjectURL(e),this.previewUrls.delete(e))}formatFile(e){return e.blob?new File([e.blob],e.fields.originalName||"file",{type:e.fields.type||e.blob.type,lastModified:e.fields.lastModified||Date.now()}):null}handleClick(e){let t=window.targetCheck(e,this.selectors.fields.dropZone);t&&!e.target.matches("input, button, a")&&t.querySelector(this.selectors.fields.input)?.click();const s=window.targetCheck(e,"[data-action]");s&&this.handleAction(s)}handleAction(e){const t=e.dataset.action,s=this.getFieldIdFromElement(e);switch(t){case"add-to-group":this.handleAddToGroup(s).then((()=>{}));break;case"delete-group":this.handleDeleteGroup(e);break;case"delete-upload":case"remove-from-group":this.handleRemoveItem(e).then((()=>{}));break;case"upload":this.queueUploads("uploads/groups",s).then((()=>{}));break;case"restore":this.handleRestoreSelected().then((()=>{}));break;case"restore-all":this.handleRestoreAll().then((()=>{}));break;case"clear-cache":this.handleClearCache().then((()=>{}))}}handleChange(e){let t=this.getFieldIdFromElement(e.target);if(t)if(e.target.matches(this.selectors.fields.input)){const s=Array.from(e.target.files);s.length>0&&this.processFiles(t,s).then((()=>{}))}else e.target.matches(this.selectors.items.checkbox)||e.target.matches(this.selectors.items.featured)||e.target.matches('[name*="select-"]')||("post_group"===this.fields.get(t).config.destination?this.handleGroupMetaChange(e.target):this.queueUploadMeta(e));else{e.target.closest("[data-upload-id], [data-attachment-id]")&&this.queueUploadMeta(e)}}handleGroupMetaChange(e){const t=e.dataset.groupId;if(!t)return;const s=e.name;if(!s)return;const i=e.value,r=s.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");window.debouncer.schedule(`group-meta-${t}-${r}`,(async()=>{const e=this.stores.groups.get(t);e&&(e.fields||(e.fields={}),e.fields[r]=i,await this.setGroup(t,e))}),300)}handleDragEnter(e){if(!e.dataTransfer.types.includes("Files"))return;const t=e.target.closest(this.selectors.fields.dropZone);t&&(e.preventDefault(),t.classList.add("dragover"))}handleDragLeave(e){const t=e.target.closest(this.selectors.fields.dropZone);t&&!t.contains(e.relatedTarget)&&t.classList.remove("dragover")}handleDragOver(e){if(!e.dataTransfer.types.includes("Files"))return;e.target.closest(this.selectors.fields.dropZone)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")}handleDrop(e){const t=e.target.closest(this.selectors.fields.dropZone);if(!t)return;e.preventDefault(),t.classList.remove("dragover"),t.classList.add("uploading");const s=Array.from(e.dataTransfer.files);if(0===s.length)return;const i=this.getFieldIdFromElement(t);i&&(this.processFiles(i,s).then((()=>{this.updateHandlerItems(i)})),this.a11y.announce(`${s.length} file(s) dropped for upload`))}async queueUploads(e,t){let s=new FormData;const i=this.fields.get(t);if(!i)return;let r=this.stores.uploads.filterByIndex({field:t});if(0===r.length)return;const[a,o]=["uploads"===e,"uploads/groups"===e];let l,d,n,u,p;s.append("fieldId",i.id),s.append("content",i.config.content),a&&(s.append("mode",i.config.mode),s.append("field_name",i.config.name),s.append("fieldId",i.id),s.append("field_type",i.config.type),s.append("subtype",i.config.subtype),s.append("item_id",i.config.itemID),s.append("destination",i.config.destination)),o?({posts:l,uploadMap:d,files:n}=this.collectGroups(t)):a&&({uploadMap:d,files:n}=this.collectUploads(t)),o&&s.append("posts",JSON.stringify(l)),n.forEach((e=>{s.append("files[]",e)})),s.append("upload_ids",JSON.stringify(d)),a?(u=`Uploading ${r.length} file${r.length>1?"s":""} to server...`,p=`Uploading ${r.length} file${r.length>1?"s":""}...`):o&&(u=`Creating ${l.length} ${i.config.content}${l.length>1?"s":""} from uploads...`,p=`Creating ${l.length} post${l.length>1?"s":""}...`),await this.setBulkUpload(r,"status","queued");let c=this.sendToQueue(e,s,u,p);if("uploads/groups"===e){let e=i.element.closest("details");e&&(e.open=!1)}return c?(i.operationId=c,await this.setBulkUpload(r,"operationId",c),await this.setBulkUpload(r,"status","uploading"),await this.setBulkGroup(t,"operationId",c),this.fields.set(i.id,i),this.notify("sent-to-queue",{field:i,operation:c})):await this.setBulkUpload(r,"status","failed"),c}async sendToQueue(e,t,s="",i="",r=!1){""===i&&(i=s);const a={endpoint:e,method:"POST",data:t,title:s,popup:i,canMerge:r,sendNow:"uploads/groups"===e,headers:{action_nonce:window.auth.getNonce("dash")},append:"_upload"};try{return await this.queue.addToQueue(a)}catch(e){return this.error.log(e,{component:"UploadManager",action:"sentToQueue"}),!1}}collectGroups(e){let t=this.stores.uploads.filterByIndex({field:e}),s=[],i=[],r=[];const a=this.stores.groups.filterByIndex({field:e}).filter((e=>{const t=this.getGroupUploadsInOrder(e);return t.length>0&&t.some((e=>this.formatFile(e)))}));for(const e of a){const t=this.groups.get(e.id)?.element,a={images:[],fields:this.collectGroupFieldsFromDOM(t,e.id)},o=this.getGroupUploadsInOrder(e);for(const t of o){const s=this.formatFile(t);if(s){r.push(s);const o={upload_id:t.id,index:i.length},l=this.uploads.get(t.id),d=l?.element?.querySelector(`input[name="${e.id}_featured"]`);d?.checked&&(a.fields.featured=t.id),a.images.push(o),i.push(t.id)}}a.images.length>0&&s.push(a)}const o=t.filter((e=>!e.group));for(const e of o){const t={images:[],fields:{}},a=this.formatFile(e);if(a){r.push(a);const s={upload_id:e.id,index:i.length};t.images.push(s),i.push(e.id)}t.images.length>0&&s.push(t)}return{posts:s,uploadMap:i,files:r}}getGroupUploadsInOrder(e){return e.uploads&&0!==e.uploads.length?e.uploads.map((e=>this.stores.uploads.get(e))).filter(Boolean):[]}collectGroupFieldsFromDOM(e,t){if(!e)return{};const s={};return e.querySelectorAll("input, textarea, select").forEach((e=>{const i=e.name.replace(`${t}[`,"").replace(`${t}_`,"").replace("]","");["featured","select-all"].some((e=>i.includes(e)))||e.value&&(s[i]=e.value)})),s}collectUploads(e){let t=this.stores.uploads.filterByIndex({field:e});if(0===t.length)return;let s=[],i=[];for(const e of t){const t=this.formatFile(e);t&&(i.push(t),s.push(e.id))}return{uploadMap:s,files:i}}queueUploadMeta(e){let t=e.target.closest("[data-attachment-id]")?.dataset.attachmentId,s=!1;if(!t&&(t=e.target.closest("[data-upload-id]")?.dataset.uploadId,s=!0,!t))return;if(!this.changes.has(t)){let e={};s?e.uploadId=t:e.attachmentId=t,this.changes.set(t,e)}let i=e.target.closest("[data-field]").dataset.field;this.changes.get(t)[i]=e.target.value,this.scheduleSave()}scheduleSave(){window.debouncer.schedule("upload-meta",(async()=>{if(this.changes.size>0){let e={};for(let[t,s]of this.changes.entries())console.log(t,s),e[t]=s;let t={user:window.auth.getUser(),items:e};await this.sendToQueue("uploads/meta",t,"Uploading Meta","Uploading Meta",!0),this.changes.clear()}}),2e3)}scanFields(e,t=!0,s=!0){e.querySelectorAll(this.selectors.fields.field).forEach((e=>this.registerField(e,t,s)))}registerField(e,t=!0,s=!0,i=null){const r={element:e,id:i||this.determineFieldId(e),config:this.extractFieldConfig(e,t,s),uploads:new Set,operationId:null,groups:[],ui:window.uiFromSelectors(this.selectors.fields,e),groupUI:window.uiFromSelectors(this.selectors.groups,e)};return this.fields.set(r.id,r),e.dataset.uploader=r.id,this.getSelectionHandler(r.id),"single"!==r.config.type&&this.initSortable(r.id),r.id}extractFieldConfig(e,t,s){return{autoUpload:t,showMeta:s,destination:e.dataset.destination||"meta",content:this.extractFieldContent(e),mode:e.dataset.mode||"direct",type:e.dataset.type||"single",name:e.dataset.field,itemID:this.extractFieldItemId(e)??0,maxFiles:parseInt(e.dataset.maxFiles)??25,subType:e.dataset.subtype??"image"}}extractFieldContent(e){return e.dataset.content||e.closest("dialog")?.dataset.content||e.closest("form")?.dataset.save||null}extractFieldItemId(e){return e.dataset.itemId||e.closest("dialog")?.dataset.itemId||null}determineFieldId(e){let t=this.extractFieldContent(e);t=null===t?"":t+"_";let s=this.extractFieldItemId(e);s=null===s?"":s+"_";return`${t}${s}${e.dataset.field||""}`}getFieldIdFromElement(e){const t=e.closest(this.selectors.fields.field);return t?.dataset.uploader||null}updateFieldProgress(e,t,s,i){const r=this.fields.get(e);r&&window.showProgress(r.ui.progress,t,s,i)}getWorker(){return this.workerState.worker||"undefined"==typeof OffscreenCanvas||(this.workerState.worker=new Worker("worker.js"),this.workerState.worker.onmessage=e=>this.handleWorkerMessage(e),this.workerState.worker.onerror=e=>this.handleWorkerError(e)),this.workerState.worker}handleWorkerMessage(e){const{id:t,blob:s}=e.data,i=this.workerState.tasks.get(t);i&&(clearTimeout(i.timeoutId),i.resolve(s),this.workerState.tasks.delete(t))}handleWorkerError(e){this.workerState.tasks.forEach((t=>{clearTimeout(t.timeoutId),t.reject(e)})),this.workerState.tasks.clear(),this.restartWorker()}restartWorker(){this.workerState.worker&&(this.workerState.worker.terminate(),this.workerState.worker=null),this.workerState.restart.count++}async processImages(e,t=2200,s=2200){const i=[],r=[...e],a=this.workerState.settings.maxConcurrent,o=async()=>{for(;r.length>0;){const e=r.shift(),a=await this.processImage(e.file,t,s);i.push({uploadId:e.uploadId,blob:a})}};return await Promise.all(Array.from({length:Math.min(a,e.length)},(()=>o()))),i}async processImage(e,t=2200,s=2200,i=3e3){if("undefined"==typeof OffscreenCanvas)return this.resizeImage(e,t,s);try{return await this.withTimeout(this.workerImage(e,t,s),i)}catch(i){return this.resizeImage(e,t,s)}}withTimeout(e,t){return Promise.race([e,new Promise(((e,s)=>setTimeout((()=>s(new Error("Timeout"))),t)))])}async workerImage(e,t=2200,s=2200){const{settings:i,restart:r}=this.workerState;if(r.count>=r.max)throw new Error("Worker max restarts exceeded");const a=await createImageBitmap(e);let{width:o,height:l}=a;if(o>t||l>s){const e=Math.min(t/o,s/l);o=Math.round(o*e),l=Math.round(l*e)}const d=this.getWorker(),n=crypto.randomUUID();return new Promise(((t,s)=>{const r=setTimeout((()=>{this.workerState.tasks.delete(n),i.restartAfterTimeout&&this.restartWorker(),s(new Error("Timeout"))}),i.timeout);this.workerState.tasks.set(n,{resolve:t,reject:s,timeoutId:r}),d.postMessage({id:n,imageBitmap:a,width:o,height:l,type:e.type,quality:.9},[a])}))}resizeImage(e,t,s){return new Promise((i=>{const r=new Image;r.onload=()=>{URL.revokeObjectURL(r.src);let{width:a,height:o}=r;if(a>t||o>s){const e=Math.min(t/a,s/o);a=Math.round(a*e),o=Math.round(o*e)}const l=document.createElement("canvas");l.width=a,l.height=o,l.getContext("2d").drawImage(r,0,0,a,o),l.toBlob(i,e.type,.9)},r.src=URL.createObjectURL(e)}))}async processFiles(e,t){let s=this.fields.get(e);if(!s)return;s.groupUI.container&&(s.groupUI.container.hidden=!1);const i=t.length;let r=0;this.updateFieldProgress(e,0,i,"Processing files...");const a=await Promise.all(t.map((async t=>{const s=window.generateID("upload"),i=await this.setUpload(s,{id:s,field:e,status:"local_processing",fields:{originalName:t.name,originalSize:t.size,type:t.type,lastModified:t.lastModified}}),r=await this.createUpload(s,t,e);return this.uploads.set(s,{element:r,ui:window.uiFromSelectors(this.selectors.items,r)}),await this.addToGroup(s,null),{uploadId:s,upload:i,file:t}}))),o=a.filter((e=>e.file.type.startsWith("image/"))),l=a.filter((e=>!e.file.type.startsWith("image/"))),d=await this.processImages(o.map((e=>({file:e.file,uploadId:e.uploadId}))));for(const{uploadId:t,blob:s}of d){const a=o.find((e=>e.uploadId===t));a&&(a.upload.blob=s,a.upload.fields.size=s.size,a.upload.status="queued",await this.setUpload(t,a.upload),r++,this.updateFieldProgress(e,r,i,"Processing files..."))}for(const{uploadId:t,upload:s,file:a}of l)s.blob=a,s.status="queued",await this.setUpload(t,s),r++,this.updateFieldProgress(e,r,i,"Processing files...");this.maybeLockUploads(e),s.config.autoUpload&&"post_group"!==s.config.destination&&await this.queueUploads("uploads",e)}async checkRecovery(){const e=this.stores.uploads.filterByIndex({status:["local_processing","queued","uploading"]}),t=Array.from(this.stores.groups.data.values());for(const e of t){this.stores.uploads.filterByIndex({group:e.id}).length>0||await this.stores.groups.delete(e.id)}if(0===e.length)return;const s=new Map;e.forEach((e=>{const t=e.src||"unknown";s.has(t)||s.set(t,[]),s.get(t).push(e)}));let i={bySource:s,pendingUploads:e};document.body.append(this.templates.create("restoreNotification",i));let r=document.querySelector("dialog.restore-uploads");this.restoreModal=new window.jvbModal(r),this.restoreSelection=new window.jvbHandleSelection(r,{wrapper:{wrapper:".restore-field",id:"selection"},items:".item-grid.restore",selectAll:{bulkControls:".selection-actions",checkbox:"#select-all-restore",count:".selection-count"}}),this.restoreModal.handleOpen()}async handleRestoreSelected(){if(!this.restoreSelection)return;let e=Array.from(this.restoreSelection.selectedItems);0!==e.length&&await this.restoreSelectedUploads(e)}async handleRestoreAll(){if(!this.restoreModal)return;const e=Array.from(this.restoreModal.modal.querySelectorAll(".item.upload")).map((e=>e.dataset.uploadId));await this.restoreSelectedUploads(e)}async restoreSelectedUploads(e){let t=window.location.href,s=Array.from(this.stores.uploads.data.values()).filter((s=>e.includes(s.id)&&s.src===t)),i=[...new Set(s.map((e=>e.group)))].filter(Boolean),r=s[0].field;if(!document.querySelector(`[data-uploader="${r}"]`))return void console.log("No field found for "+r);let a=this.fields.get(r);a.groupUI.container&&(a.groupUI.container.hidden=!1);let o=[];for(let e of i){let t=this.stores.groups.get(e);await this.createGroup(r,e);let i=this.groups.get(e),a=s.filter((t=>t.group===e));if(t&&this.groups.has(e)){let e=t.fields;for(const[t,s]of Object.entries(e)){let e=i.element.querySelector(`input[name*="${t}"]`);e&&(e.value=s)}}else e=null;for(let t of a){let s=await this.createUpload(t.id,this.formatFile(t),r);this.uploads.set(t.id,{element:s,ui:window.uiFromSelectors(this.selectors.items,s)}),await this.addToGroup(t.id,e),o.push(t.id)}}let l=s.filter((e=>!o.includes(e.id)));for(let e of l){let t=await this.createUpload(e.id,this.formatFile(e),r);this.uploads.set(e.id,{element:t,ui:window.uiFromSelectors(this.selectors.items,t)}),await this.addToGroup(e.id,null)}this.cleanupRestore()}cleanupRestore(){this.restoreModal.handleClose(),this.restoreSelection.destroy(),this.restoreSelection=null,this.restoreModal.destroy(),this.restoreModal.modal.remove(),this.restoreModal=null}getStatusText(e){return{received:"Image Received",local_processing:"Processing Image...",queued:"Waiting to upload...",uploading:"Uploading to Server",pending:"Successfully sent to server. In line for further processing.",processing:"Processing on server...",completed:"Upload complete!",failed:"Upload failed (will retry)",failed_permanent:"Upload failed permanently"}[e]||e}getStatusProgress(e){return{local_processing:28,queued:50,uploading:66,pending:75,processing:89,completed:100}[e]??0}async createUpload(e,t,s){let i=this.fields.get(s);if(!i)return null;let r={uploadId:e,file:t,field:i};return this.templates.create("uploadItem",r)}getSubtypeFromURL(e){const t=e.split("?")[0].toLowerCase();return[".webp",".jpg",".jpeg",".png",".gif",".svg"].some((e=>t.endsWith(e)))?"image":[".mp4",".ogg",".mov",".webm",".avi"].some((e=>t.endsWith(e)))?"video":"document"}getSubtypeFromMime(e){return e.startsWith("image/")?"image":e.startsWith("video/")?"video":"document"}async handleRemoveItem(e){const t=e.closest(this.selectors.items.item);if(!t)return;const s=t.dataset.uploadId;confirm("Remove this item?")&&(await this.removeUpload(s),this.a11y.announce("Item removed"))}async setBulkUpload(e,t,s){const i=Array.from(e).map((async e=>{if("string"==typeof e&&(e=await this.stores.uploads.get(e)),e)return"status"===t&&await this.setUploadStatus(e,s),e[t]=s,this.stores.uploads.save(e)}));await Promise.all(i)}async setUploadStatus(e,t){"string"==typeof e&&(e=await this.stores.uploads.get(e)),e&&e.progress&&window.showProgress(e.progress,this.getStatusProgress(t),100,this.getStatusText(t),this.queue.icons[t]??"")}async removeUpload(e){let t=this.stores.uploads.get(e);if(!t)return;if(t.group){let s=this.stores.groups.get(t.group);s.uploads=s.uploads.filter((t=>t!==e)),0===s.uploads.length?await this.removeGroup(s.id,!1):await this.stores.groups.save(s)}await this.clearUpload(e),this.maybeLockUploads(t.field);let s=this.selectionHandlers.get(t.field);s&&s.deselect(e),this.a11y.announce("Upload removed")}async clearUpload(e){const t=this.uploads.get(e);if(t&&(this.revokePreviewUrl(t.preview),t.element)){const e=t.element.dataset.previewUrl;this.revokePreviewUrl(e),t.element.remove()}this.uploads.delete(e),await this.stores.uploads.delete(e)}async handleAddToGroup(e){const t=this.selected.get(e);if(!t||0===t.size)return;let s=await this.createGroup(e);s&&(await Promise.all(Array.from(t).map((e=>this.addToGroup(e,s)))),this.selectionHandlers.get(e)?.clearSelection(),this.a11y.announce(`Created group with ${t.size} items`))}async createGroup(e,t=null){let s=this.fields.get(e);if(!s)return;t||(t=window.generateID("group"));const i=this.createGroupElement(t,e);if(!i)return null;const r=s.groupUI.empty;r?.nextSibling?s.groupUI.grid.insertBefore(i,r.nextSibling):s.groupUI.grid.append(i);const a=i.querySelector(".item-grid");a&&(a.dataset.groupId=t,this.createSortable(e,a,t));let o=this.stores.groups.data.has(t)?this.stores.groups.data.get(t):{};return await this.setGroup(t,{...o,id:t,field:e}),t}createGroupElement(e,t=null){let s={groupId:e,fieldId:t},i=this.templates.create("imageGroup",s);return this.groups.set(e,{element:i,ui:window.uiFromSelectors(this.selectors.group,i)}),this.getSelectionHandler(t)?.addWrapper(i),i}async setGroup(e,t){const s={...{id:e,src:window.location.href,uploads:[],operationId:null,field:null,fields:{}},...t};Object.preventExtensions(s),await this.stores.groups.save(s)}async setBulkGroup(e,t,s){let i=this.stores.groups.filterByIndex({field:e});if(0===i.length)return;let r=i.map((e=>{e[t]=s,this.stores.groups.save(e)}));await Promise.all(r)}async addToGroup(e,t=null){const s=this.stores.uploads.get(e),i=this.uploads.get(e);if(!s||!i)return;const r=this.fields.get(s.field);if(!r)return;if(null!==i.element?.parentElement&&(!t&&null===s.group||t===s.group))return void this.handleReorder(s.field,t);if(s.group){const t=this.stores.groups.get(s.group);t&&(t.uploads=t.uploads.filter((t=>t!==e)),0===t.uploads.length?await this.removeGroup(t.id,!1):await this.stores.groups.save(t))}i.ui.checkbox&&(i.ui.checkbox.checked=!1);const a=this.selectionHandlers.get(s.field);if(a&&a.isSelected(e)&&a.deselect(e),this.selected.get(s.field)?.has(e)&&this.selected.get(s.field).delete(e),i.ui.featured&&(i.ui.featured.hidden=!t),t){i.ui.featured&&(i.ui.featured.name=`${t}_featured`);let r=this.stores.groups.get(t);r&&(r.uploads.push(e),s.group=t,await this.stores.groups.save(r))}else s.group=null;let o=t?this.groups.get(t)?.ui.grid:r.ui.grid;o&&(o.append(i.element),t&&await this.handleReorder(s.field,t)),await this.stores.uploads.save(s)}handleDeleteGroup(e){const t=e.closest(this.selectors.group.item);if(!t)return;let s=t.dataset.groupId;if(!confirm("Delete this group? Items will be moved back to the upload area."))return;let i=this.stores.uploads.filterByIndex({group:s});Promise.all(i.map((e=>this.addToGroup(e.id,null)))).then((()=>{this.removeGroup(s,!1).then((()=>{})),this.a11y.announce("Group deleted. Items returned to upload area")}))}async removeGroup(e,t=!0){let s=this.groups.get(e),i=this.stores.groups.get(e);if(!i)return;let r=!0;t&&i.uploads.length>0&&(r=window.confirm("Keep uploads in this group?")),await Promise.all(i.uploads.map((e=>r?this.addToGroup(e,null):this.removeUpload(e))));if(this.fields.get(i.field)){const t=this.getGroupKey(i.field,e),r=this.selectionHandlers.get(t);r?.destroy&&r.destroy(),this.selectionHandlers.get(i.field)?.removeWrapper(s.element);const a=this.sortables.get(t);a?.destroy&&a.destroy(),this.sortables.delete(t)}s?.element&&s.element.remove(),this.groups.delete(e),await this.stores.groups.delete(e),this.a11y.announce("Group removed")}maybeLockUploads(e){let t=this.fields.get(e);if(!t||!t.ui.dropZone)return;let s=this.stores.uploads.filterByIndex({field:e}).length,i=t.config.maxFiles??25;t.ui.dropZone.hidden=s>=i}async handleOperationCancelled(e){0!==e.length&&e.forEach((e=>{this.removeUpload(e)}))}getGroupKey(e,t=null){return t?`${e}_${t}`:`${e}`}getSelectionHandler(e){let t=this.getGroupKey(e);if(!this.selectionHandlers.has(t)){let s=this.fields.get(e);if(!s)return;if("post_group"!==s.config.destination)return;let i=new window.jvbHandleSelection(s.element,{selectAll:{checkbox:this.selectors.fields.selectAll,count:this.selectors.fields.count,bulkControls:this.selectors.fields.actions},item:{item:this.selectors.items.item,checkbox:this.selectors.items.checkbox,idAttribute:"uploadId"},wrapper:{wrapper:".preview-wrap, .upload-group",id:"groupId"}});i.subscribe(((t,s)=>{this.selected.set(e,s.selectedItems)})),this.selectionHandlers.set(t,i)}return this.selectionHandlers.get(t)}updateHandlerItems(e){let t=this.getSelectionHandler(e);t&&t.collectItems()}initSortable(e){if(!window.Sortable)return;const t=this.fields.get(e);t&&(!Sortable._multiDragMounted&&Sortable.MultiDrag&&(Sortable.mount(new Sortable.MultiDrag),Sortable._multiDragMounted=!0),this.createSortable(e,t.ui.grid,null),this.initEmptyGroupDropZone(e))}createSortable(e,t,s){if(!t)return null;const i=this.getGroupKey(e,s);if(this.sortables.has(i))return this.sortables.get(i);const r=new Sortable(t,{animation:150,draggable:".item",multiDrag:!0,selectedClass:"selected",avoidImplicitDeselect:!0,group:{name:e,pull:!0,put:!0},dragClass:"dragging",ignore:".empty-group",onStart:t=>{const s=t.item,i=s?.dataset.uploadId,r=this.selected.get(e);if(i&&(!r||!r.has(i))){const t=this.selectionHandlers.get(e);t&&t.select(i)}},onEnd:t=>this.sortableDrop(t,e)});return this.sortables.set(i,r),r}initEmptyGroupDropZone(e){const t=this.fields.get(e),s=t?.groupUI?.empty;s&&(s.addEventListener("dragover",(e=>{e.preventDefault(),e.stopPropagation(),e.dataTransfer.dropEffect="move",s.classList.add("drag-over")})),s.addEventListener("dragleave",(e=>{s.contains(e.relatedTarget)||s.classList.remove("drag-over")})),s.addEventListener("drop",(async t=>{t.preventDefault(),t.stopPropagation(),s.classList.remove("drag-over");const i=this.selected.get(e);if(!i||0===i.size)return;const r=await this.createGroup(e);r&&(await Promise.all(Array.from(i).map((e=>this.addToGroup(e,r)))),this.selectionHandlers.get(e)?.clearSelection())})))}async sortableDrop(e,t){const s=e.to,i=(e.items?.length>0?Array.from(e.items):[e.item]).map((e=>e.dataset.uploadId)).filter(Boolean);if(0===i.length)return;const r=s.dataset.groupId||null;for(const e of i)await this.addToGroup(e,r);await this.handleReorder(t,r),this.selectionHandlers.get(t)?.clearSelection()}handleReorder(e,t=null){let s=t?this.groups.get(t)?.ui.grid:this.fields.get(e)?.ui.grid;if(!s)return void console.log("Couldn't Reorder items...");let i=Array.from(s.children).filter((e=>e.matches(this.selectors.items.item)&&!e.classList.contains("ghost"))).map((e=>e.dataset.uploadId)).filter((e=>e));if(t){let e=this.stores.groups.get(t);e&&(e.uploads=i,this.stores.groups.save(e).then((()=>{})))}else{let t=this.fields.get(e)?.ui.hidden;t&&(t.value=i.join(","))}this.a11y.announce("Items reordered")}subscribe(e){return this.subscribers.add(e),()=>this.subscribers.delete(e)}notify(e,t={}){this.subscribers.forEach((s=>{try{s(e,t)}catch(e){console.error("Subscriber error:",e)}}))}destroy(){this.subscribers.clear(),this.previewUrls.forEach((e=>{this.revokePreviewUrl(e)})),this.previewUrls.clear()}cleanupAllPreviewUrls(){this.previewUrls.forEach((e=>this.revokePreviewUrl(e))),this.previewUrls.clear()}async handleClearCache(){const e=window.location.href,t=this.stores.uploads.filterByIndex({src:e}),s=this.stores.groups.filterByIndex({src:e});await Promise.all([...t.map((e=>this.clearUpload(e.id))),...s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id))))]),this.restoreModal&&this.cleanupRestore(),this.a11y.announce("Cache cleared for this page")}async getFilesForForm(e){const t=e.querySelectorAll(this.selectors.fields.field),s=[];for(const e of t){const t=this.determineFieldId(e),i=e.dataset.field,r=this.stores.uploads.filterByIndex({field:t});for(const e of r){const t=this.formatFile(e);t&&s.push({file:t,fieldName:i,uploadId:e.id,meta:e.fields||{}})}}return s}async clearFieldFromStores(e){const t=this.stores.uploads.filterByIndex({field:e}),s=this.stores.groups.filterByIndex({field:e});await Promise.all(t.map((e=>this.clearUpload(e.id)))),await Promise.all(s.map((e=>(this.groups.get(e.id)?.element?.remove(),this.groups.delete(e.id),this.stores.groups.delete(e.id)))))}}document.addEventListener("DOMContentLoaded",(async function(){window.auth.subscribe((t=>{"auth-loaded"===t&&(window.jvbUploads=new e)}))}))})(); |
| | |
| | | * Example: |
| | | * [ |
| | | * 'member_content' => true, |
| | | * 'invitable' => true, |
| | | * 'can_invite' => ['artist' => ['artist']], |
| | | * 'member_verified' => true, |
| | | * 'notifications' => true, |
| | |
| | | <?php |
| | | /** |
| | | * Custom options for the site can be set here. They will need to be handled by the child plugin, but you can make use of the MetaManager.php this way |
| | | * Custom options for the site can be set here. They will need to be handled by the child plugin, but you can make use of the Meta.php this way |
| | | */ |
| | | |
| | | $options = apply_filters('jvb_options', []); |
| | |
| | | * - approve_new = (bool) if true, admin/verified users need to approve before 'live' |
| | | * - track_changes = (bool) if true, table is created to track historical changes |
| | | * - for_content = (array) of post type slugs, as defined in JVB_CONTENT |
| | | * - fields = (array) of custom field definitions, from inc/managers/MetaManager.php |
| | | * - fields = (array) of custom field definitions, from inc/managers/Meta.php |
| | | * -> add use_in_stats (bool) to use the field in user statistics |
| | | */ |
| | | $taxonomies = apply_filters('jvb_taxonomy', []); |
| | |
| | | <?php |
| | | |
| | | use JVBase\managers\Cache; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\meta\Render; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; // Exit if accessed directly |
| | |
| | | } |
| | | |
| | | ob_start(); |
| | | $meta = new JVBase\meta\MetaManager((int)$current->ID, 'post'); |
| | | $meta = Meta::forPost($current->ID); |
| | | $artist = jvbContentFromUser((int)$current->post_author); |
| | | |
| | | $sections = JVB_CONTENT[jvbNoBase($current->post_type)]['sections']??[]; |
| | |
| | | </li> |
| | | </ul> |
| | | <?php endif; ?> |
| | | <?php $styles = $meta->getValue('top_styles'); |
| | | <?php $styles = $meta->get('top_styles'); |
| | | if (!empty($styles)) { |
| | | ?> |
| | | <ul class="term-list style"> |
| | |
| | | </summary> |
| | | <div class="columns stack-small"> |
| | | <div class="column"> |
| | | <?php $meta->render('render', 'image_portrait'); ?> |
| | | <?= Render::renderFrom($meta, 'image_portrait'); ?> |
| | | </div> |
| | | <div class="column"> |
| | | <?php $meta->render('render', 'short_bio'); ?> |
| | | <?= Render::renderFrom($meta, 'short_bio'); ?> |
| | | </div> |
| | | </div> |
| | | <div id="styles"> |
| | |
| | | </div> |
| | | |
| | | <div id="about"> |
| | | <?php $meta->render('render', 'bio')?> |
| | | <?= Render::renderFrom($meta, 'bio')?> |
| | | </div> |
| | | </details> |
| | | </section> |
| | | <section id="contact" class=""> |
| | | <h2>Contact <?=$artist['name']?></h2> |
| | | <?php |
| | | echo jvbRenderContactInfo($current->ID, $meta); |
| | | echo jvbRenderLinks($current->ID, $meta); |
| | | echo jvbRenderContactInfo($current->ID, 'post'); |
| | | echo jvbRenderLinks($current->ID, 'post'); |
| | | ?> |
| | | </section> |
| | | <?php |
| | |
| | | $cache = Cache::for('shop_bio', WEEK_IN_SECONDS)->connect('taxonomy'); |
| | | $key = $current->term_id; |
| | | $cached = $cache->get($key); |
| | | $cached = false; |
| | | if ($cached !== false) { |
| | | return $cached; |
| | | } |
| | | |
| | | ob_start(); |
| | | |
| | | $meta = new JVBase\meta\MetaManager($current->term_id, 'term'); |
| | | $rating = $meta->getValue('average_rating'); |
| | | $opened = $meta->getValue('established'); |
| | | $about = $meta->getValue('bio'); |
| | | $location = $meta->getValue('location'); |
| | | $hours = $meta->getValue('hours'); |
| | | $specialty = $meta->getValue('specialties'); |
| | | $awards = $meta->getValue('awards'); |
| | | $reviews = $meta->getValue('reviews'); |
| | | |
| | | $meta = Meta::forTerm($current->term_id); |
| | | $fields = $meta->getAll(['average_rating', 'established', 'bio','location','hours','specialties','awards','reviews']); |
| | | ?> |
| | | <nav id="shop" class="on-this-page index"> |
| | | <label>Jump to: |
| | |
| | | </label> |
| | | <ul> |
| | | <li><a href="#top" title="Back to Top"><?=jvbIcon('caret-circle-up')?></a></li> <?php |
| | | if ($rating !== 'none') { |
| | | if ($fields['rating'] !== 'none') { |
| | | ?> |
| | | <li><a href="#rating">Rating</a></li> |
| | | <?php |
| | | } elseif ($opened !== '') { |
| | | } elseif ($fields['opened'] !== '') { |
| | | ?> |
| | | <li><a href="#opened">Opened</a></li> |
| | | <?php |
| | | } elseif ($location !== '') { |
| | | } elseif ($fields['location'] !== '') { |
| | | ?> |
| | | <li><a href="#location">Location</a></li> |
| | | <?php |
| | | } elseif ($about !== '') { |
| | | } elseif ($fields['about'] !== '') { |
| | | ?> |
| | | <li><a href="#about">About</a></li> |
| | | <?php |
| | | } elseif ($hours !== '') { |
| | | } elseif ($fields['hours'] !== '') { |
| | | ?> |
| | | <li><a href="#hours">Hours</a></li> |
| | | <?php |
| | | } elseif ($specialty !== '') { |
| | | } elseif ($fields['specialties'] !== '') { |
| | | ?> |
| | | <li><a href="#specialties">Specialties</a></li> |
| | | <?php |
| | | } elseif ($awards !== '') { |
| | | } elseif ($fields['awards'] !== '') { |
| | | ?> |
| | | <li><a href="#awards">Awards</a></li> |
| | | <?php |
| | | } elseif ($reviews !== '') { |
| | | } elseif ($fields['reviews'] !== '') { |
| | | ?> |
| | | <li><a href="#reviews">Reviews</a></li> |
| | | <?php |
| | |
| | | <header id="top"> |
| | | <div class="columns stack-small"> |
| | | <div class="column"> |
| | | <?=jvbFormatImage($meta->getValue('image'))?> |
| | | <?=jvbFormatImage($meta->get('image'))?> |
| | | </div> |
| | | <div class="column"> |
| | | <h1> |
| | | <small><?= (get_term((int)$meta->getValue('city'), BASE.'city')) ? |
| | | get_term((int)$meta->getValue('city'), BASE.'city')->name : |
| | | <small><?= (get_term((int)$meta->get('city'), BASE.'city')) ? |
| | | get_term((int)$meta->get('city'), BASE.'city')->name : |
| | | 'Edmonton'?>'s Best Tattoo Shops</small> |
| | | <?=$current->name?> |
| | | </h1> |
| | | <?= jvbFormatRating($current->term_id, $meta) ?> |
| | | <?php $meta->render('render', 'slogan'); ?> |
| | | <?= jvbFormatRating($current->term_id, 'term') ?> |
| | | <?= Render::renderFrom($meta, 'slogan'); ?> |
| | | </div> |
| | | </div> |
| | | </header> |
| | |
| | | <h2>Learn More About <?=$current->name?></h2> |
| | | </summary> |
| | | <div class="map"> |
| | | <?php $meta->render('render', 'location'); ?> |
| | | <?= Render::renderFrom($meta, 'location'); ?> |
| | | </div> |
| | | <div class="short-bio"> |
| | | <?php $meta->render('render', 'short_bio'); ?> |
| | | <?= Render::renderFrom($meta, 'short_bio'); ?> |
| | | </div> |
| | | |
| | | <div class="contact"> |
| | | <h3>Contact:</h3> |
| | | <?php |
| | | echo jvbRenderContactInfo($current->term_id, $meta); |
| | | echo jvbRenderLinks($current->term_id, $meta); |
| | | echo jvbRenderContactInfo($current->term_id, 'term'); |
| | | echo jvbRenderLinks($current->term_id, 'term'); |
| | | ?> |
| | | </div> |
| | | |
| | | <div id="about"> |
| | | <?php $meta->render('render', 'bio')?> |
| | | <?= Render::renderFrom($meta, 'bio')?> |
| | | </div> |
| | | </details> |
| | | </section> |
| | | <section id="contact" class=""> |
| | | <h2>Contact </h2> |
| | | <?php |
| | | echo jvbRenderContactInfo($current->term_id, $meta); |
| | | echo jvbRenderLinks($current->term_id, $meta); |
| | | echo jvbRenderContactInfo($current->term_id, 'term'); |
| | | echo jvbRenderLinks($current->term_id, 'term'); |
| | | ?> |
| | | </section> |
| | | <?= jvbRenderHours($current->term_id, $meta)?> |
| | | <?= jvbRenderHours($current->term_id, 'term')?> |
| | | |
| | | |
| | | <?php |
| | |
| | | $title = ''; |
| | | } |
| | | |
| | | $meta = new JVBase\meta\MetaManager($current->ID, 'term'); |
| | | $meta = Meta::forTerm($current->ID); |
| | | $fields = JVB_TAXONOMY[$tax]['fields']??[]; |
| | | |
| | | ?> |
| | |
| | | BASE . 'gallery' |
| | | ]; |
| | | |
| | | //TODO: Dynamically use MetaManager to get any image or gallery fields |
| | | //TODO: Dynamically use Meta.php to get any image or gallery fields |
| | | foreach ($meta_fields as $meta_key) { |
| | | $meta_value = get_post_meta($post_id, $meta_key, true); |
| | | |
| | |
| | | <?php |
| | | namespace JVBase; |
| | | |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\Meta; |
| | | use WP_User; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | |
| | | protected int $user_id; |
| | | protected int $profileID; |
| | | protected WP_User $user_data; |
| | | protected MetaManager $meta; |
| | | protected Meta $meta; |
| | | protected string $base_url = 'https://edmonton.ink'; |
| | | protected string $badge_url; |
| | | |
| | |
| | | $this->user_id = $user_id; |
| | | $this->profileID = get_user_meta($user_id, BASE . 'link', true); |
| | | $this->user_data = get_userdata($user_id); |
| | | $this->meta = new MetaManager($this->profileID, 'post'); |
| | | |
| | | $this->meta = Meta::forPost($this->profileID); |
| | | |
| | | // Set badge URL - this would be your badge image path |
| | | $this->badge_url = JVB_URL . 'assets/images/badges/edmonton-ink-badge.png'; |
| | |
| | | protected function getArtistStyles():array |
| | | { |
| | | $styles = []; |
| | | $top_styles = $this->meta->getValue('top_style'); |
| | | $top_styles = $this->meta->get('top_style'); |
| | | |
| | | if (!empty($top_styles)) { |
| | | $style_ids = explode(',', $top_styles); |
| | |
| | | namespace JVBase\blocks; |
| | | |
| | | use JVBase\managers\Cache; |
| | | use JVBase\forms\TaxonomySelector; |
| | | use JVBase\meta\MetaManager; |
| | | use WP_Block; |
| | | use WP_Query; |
| | | |
| | |
| | | namespace JVBase\blocks; |
| | | |
| | | use JVBase\managers\Cache; |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\managers\CloudflareTurnstile; |
| | | use Exception; |
| | | use JVBase\meta\Form; |
| | | use JVBase\utility\Features; |
| | | use WP_Block; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; // Exit if accessed directly |
| | |
| | | // Initialize forms from filter |
| | | $this->forms = $this->registerForms(); |
| | | $this->form_contact = apply_filters('jvb_form_contact', ''); |
| | | |
| | | |
| | | // Hook into the CustomBlocks render system |
| | | add_filter('jvb_render_block_jvb_forms', [$this, 'render'], 10, 2); |
| | | |
| | |
| | | $this->renderTurnstile(); |
| | | $this->renderFormEnd($type, $form_id); |
| | | echo '</div>'; |
| | | |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | |
| | | return; |
| | | } |
| | | |
| | | // Create MetaManager instance for form rendering |
| | | $meta = new MetaManager(); |
| | | |
| | | // If sections are defined, render in sections |
| | | if (!empty($form_config['sections'])) { |
| | | $this->renderSections($type, $meta); |
| | | $this->renderSections($type); |
| | | } else { |
| | | echo jvbFormStatus(); |
| | | // Render fields directly |
| | | foreach ($form_config['fields'] as $field_name => $field_config) { |
| | | $meta->render('form', $field_name, $field_config); |
| | | echo Form::render($field_name, null, $field_config); |
| | | } |
| | | $submit_text = $form_config['submit'] ?? 'Submit'; |
| | | echo '<button type="submit" class="button primary">' . esc_html($submit_text) . '</button>'; |
| | |
| | | /** |
| | | * Render form sections |
| | | */ |
| | | protected function renderSections(string $type, MetaManager $meta): void |
| | | protected function renderSections(string $type): void |
| | | { |
| | | $form_config = $this->forms[$type]; |
| | | $sections = $form_config['sections']; |
| | |
| | | }); |
| | | |
| | | foreach ($section_fields as $field_name => $field_config) { |
| | | $meta->render('form', $field_name, $field_config); |
| | | echo Form::render($field_name, null, $field_config); |
| | | } |
| | | |
| | | // Add step navigation buttons |
| | |
| | | $glossary = []; |
| | | if ($posts->have_posts()) { |
| | | foreach($posts->posts as $post) { |
| | | // $meta = new MetaManager($post, 'post'); |
| | | // $meta = Meta::forPost($post); |
| | | // $fields = $meta->getAll(); |
| | | // $glossary[$fields['post_slug']] = $fields; |
| | | $glossary[$post->post_name] = [ |
| | |
| | | |
| | | use JVBase\managers\Cache; |
| | | use JVBase\forms\TaxonomySelector; |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\Form; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\meta\Render; |
| | | use WP_Block; |
| | | use WP_Query; |
| | | |
| | |
| | | protected function getSections():array |
| | | { |
| | | if (!$this->sections) { |
| | | $options = new MetaManager(null, 'options'); |
| | | $sections = $options->getValue('menu_section_order'); |
| | | $options = Meta::forOptions('options'); |
| | | $sections = $options->get('menu_section_order'); |
| | | if (!is_array($sections)) { |
| | | $sections = []; |
| | | } |
| | |
| | | } |
| | | |
| | | protected function renderMenuItem(int $ID, string $slug, string $postType = 'menu_item') { |
| | | $meta = new MetaManager($ID, 'post'); |
| | | $meta = Meta::forPost($ID); |
| | | $values = $meta->getAll([ |
| | | 'post_title', |
| | | '_square_catalog_id', |
| | |
| | | <p class="price"><?= $priceRange ?></p> |
| | | </div> |
| | | <div class="description"> |
| | | <?php $meta->render('render', 'post_excerpt')?> |
| | | <?= Render::renderFrom($meta, 'post_excerpt')?> |
| | | </div> |
| | | <div class="info row end"> |
| | | <?php |
| | | if (empty($variations)) { |
| | | $meta->render( |
| | | 'form', |
| | | Form::renderFrom($meta, |
| | | $ID.'|cart_quantity', |
| | | [ |
| | | 'type' => 'number', |
| | |
| | | foreach ($variations as $index =>$row) { |
| | | jvbDump($index, 'index'); |
| | | jvbDump($row, 'row'); |
| | | $meta->render( |
| | | 'form', |
| | | Form::renderFrom($meta, |
| | | 'quantity-'.$index, |
| | | [ |
| | | 'type' => 'number', |
| | |
| | | namespace JVBase\blocks; |
| | | |
| | | use JVBase\managers\Cache; |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\utility\Features; |
| | | use WP_Block; |
| | | |
| | |
| | | } |
| | | protected function renderHeader():void |
| | | { |
| | | $meta = new MetaManager($this->parentID, 'post'); |
| | | $meta = Meta::forPost($this->parentID); |
| | | $sharedFields = JVB()->routes('content')->getTimelineSharedFields($this->content); |
| | | $fields = $meta->getAll($sharedFields); |
| | | $extra = $meta->getAll(); |
| | |
| | | $uniqueFields = JVB()->routes('content')->getTimelineUniqueFields($this->content); |
| | | |
| | | foreach ($all as $i => $ID) { |
| | | $meta = new MetaManager($ID, 'post'); |
| | | $meta = Meta::forPost($ID); |
| | | $fields = $meta->getAll($uniqueFields); |
| | | |
| | | $plural = ($i>1) ? 's': ''; |
| | |
| | | } |
| | | |
| | | /** |
| | | * Render post selector field for MetaForm integration |
| | | * Render post selector field for Meta's Form integration |
| | | * |
| | | * @param string $name Field name |
| | | * @param mixed $value Current value |
| | |
| | | */ |
| | | function jvb_do_once():void |
| | | { |
| | | |
| | | // delete_option(BASE.'do_these_once'); |
| | | $options = get_option(BASE.'do_these_once', []); |
| | | |
| | | foreach ($options as $option => $callback) { |
| | | // delete_option($option); |
| | | if (!get_option($option, false)) { |
| | | error_log('Calling do once: '.$option); |
| | | $callback(); |
| | | update_option($option, true); |
| | | } |
| | |
| | | } |
| | | |
| | | use JVBase\managers\Cache; |
| | | use JVBase\meta\Form; |
| | | |
| | | /** |
| | | * For whatever reason, after much testing, it seems that |
| | |
| | | echo $nav; |
| | | |
| | | $fields = jvbGetFields($postType); |
| | | |
| | | $meta = new JVBase\meta\MetaManager($ID, $contentType); |
| | | ?> |
| | | <form class="jvb-form" id="bio" data-form-id="bio-<?=$ID?>" data-save="bio" |
| | | data-object-id="<?=$ID?>" data-content-type="<?=$postType?>"> |
| | |
| | | if (array_key_exists('role', $config)) { |
| | | $user = get_userdata($ID); |
| | | if (in_array($config['role'], $user->roles)) { |
| | | $meta->render('form', $field, $config); |
| | | echo Form::render($field, null, $config); |
| | | } |
| | | } else { |
| | | $meta->render('form', $field, $config); |
| | | echo Form::render($field, null, $config); |
| | | } |
| | | } |
| | | ?> |
| | |
| | | <?php |
| | | |
| | | use JVBase\managers\Cache; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\utility\Image; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | |
| | | |
| | | /** |
| | | * @param int $ID |
| | | * @param JVBase\meta\MetaManager|null $meta |
| | | * |
| | | * @param string $type 'post', 'user', or 'term' |
| | | * @return string |
| | | */ |
| | | function jvbFormatRating(int $ID, JVBase\meta\MetaManager|null $meta = null):string |
| | | function jvbFormatRating(int $ID, string $type = 'post'):string |
| | | { |
| | | $cache = Cache::for('rating', WEEK_IN_SECONDS)->connect('post')->connect('taxonomy')->connect('user'); |
| | | |
| | |
| | | return $cached; |
| | | } |
| | | |
| | | if (!$meta) { |
| | | if (term_exists((int)$ID)) { |
| | | $type = 'term'; |
| | | } elseif (get_post_status((int)$ID)) { |
| | | $type = 'post'; |
| | | } else { |
| | | $type = 'user'; |
| | | } |
| | | $meta = new JVBase\meta\MetaManager($ID, $type); |
| | | } |
| | | $meta = match ($type) { |
| | | 'term' => Meta::forTerm($ID), |
| | | 'post' => Meta::forPost($ID), |
| | | 'user' => Meta::forUser($ID), |
| | | default => false |
| | | }; |
| | | if (!$type) { |
| | | return ''; |
| | | } |
| | | |
| | | |
| | | $out = ''; |
| | | $avg = $meta->getValue('average_rating'); |
| | | $avg = $meta->get('average_rating'); |
| | | |
| | | $total = $meta->getValue('total_ratings'); |
| | | $total = $meta->get('total_ratings'); |
| | | if ($avg !== 'none') { |
| | | $out .= jvbFormatStarRating($avg, (int)$total); |
| | | } |
| | |
| | | <?php |
| | | |
| | | use JVBase\meta\Form; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | function jvbRenderForm(string $endpoint, array $fields, JVBase\meta\MetaManager $meta, array $options = [], bool $return = false):mixed |
| | | function jvbRenderForm(string $endpoint, array $fields, array $options = [], bool $return = false):mixed |
| | | { |
| | | ob_start(); |
| | | ?> |
| | |
| | | } |
| | | |
| | | foreach ($fields as $field => $config) { |
| | | $meta->render('form', $field, $config); |
| | | echo Form::render($field, null, $config); |
| | | } |
| | | ?> |
| | | <?= (jvbCheck('submit', $options)) ? '<button type="submit">'.jvbIcon('floppy-disk').'Save</button>' : '' ?> |
| | |
| | | <?php |
| | | |
| | | use JVBase\managers\Cache; |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\Meta; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | |
| | | } |
| | | $id = (int) get_user_meta($userID, BASE.'link', true); |
| | | |
| | | $meta = new MetaManager($id,'post'); |
| | | $meta = Meta::forPost($id); |
| | | $artist = $meta->getAll(['first_name','type','city','shop']); |
| | | $artist['id'] = $id; |
| | | $artist['display_name'] = $user->display_name; |
| | |
| | | |
| | | use JVBase\forms\TaxonomySelector; |
| | | use JVBase\managers\Cache; |
| | | use JVBase\meta\MetaForm; |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\Form; |
| | | use JVBase\meta\Meta; |
| | | |
| | | /** |
| | | * Outputs a toggle text that visually changes based on selection, like a switch |
| | |
| | | |
| | | /** |
| | | * @param int $ID |
| | | * @param MetaManager|null $meta |
| | | * |
| | | * @param string $type |
| | | * @return string |
| | | */ |
| | | function jvbRenderLinks(int $ID, MetaManager|null $meta = null):string |
| | | function jvbRenderLinks(int $ID, string $type =''):string |
| | | { |
| | | $cache = Cache::for('user_links', WEEK_IN_SECONDS)->connect('post')->connect('taxonomy')->connect('user'); |
| | | $cached = $cache->get($ID); |
| | |
| | | return $cached; |
| | | } |
| | | |
| | | if (!$meta) { |
| | | $meta = jvbGetMeta($ID); |
| | | } |
| | | $meta = match($type){ |
| | | 'post' => Meta::forPost($ID), |
| | | 'term' => Meta::forTerm($ID), |
| | | 'user' => Meta::forUser($ID), |
| | | default => false |
| | | }; |
| | | if (!$meta) { |
| | | $meta = jvbGetMeta($ID); |
| | | } |
| | | if (!$meta) { |
| | | return ''; |
| | | } |
| | | |
| | | $links = $meta->getValue('links'); |
| | | $links = $meta->get('links'); |
| | | |
| | | $out = ''; |
| | | if (!empty($links)) { |
| | |
| | | |
| | | /** |
| | | * @param int $ID |
| | | * @param MetaManager|null $meta |
| | | * @param string $type |
| | | * |
| | | * @return string |
| | | */ |
| | | function jvbRenderContactInfo(int $ID, MetaManager|null $meta = null):string |
| | | function jvbRenderContactInfo(int $ID, string $type = ''):string |
| | | { |
| | | $cache = Cache::for('contact', WEEK_IN_SECONDS)->connect('post')->connect('taxonomy'); |
| | | |
| | |
| | | if($cached){ |
| | | return $cached; |
| | | } |
| | | if (!$meta) { |
| | | $meta = jvbGetMeta($ID); |
| | | } |
| | | $meta = match($type){ |
| | | 'post' => Meta::forPost($ID), |
| | | 'term' => Meta::forTerm($ID), |
| | | 'user' => Meta::forUser($ID), |
| | | default => false |
| | | }; |
| | | if (!$meta) { |
| | | $meta = jvbGetMeta($ID); |
| | | } |
| | | if (!$meta) { |
| | | return ''; |
| | | } |
| | | |
| | | $preference = $meta->getValue('public_contact'); |
| | | $preference = $meta->get('public_contact'); |
| | | $preference = (is_array($preference)) ? $preference : explode(',', $preference); |
| | | |
| | | $out = ''; |
| | | if (!empty($preference)) { |
| | | $out = '<ul class="contact">'; |
| | | $phone = $meta->getValue('phone'); |
| | | $phone = $meta->get('phone'); |
| | | foreach ($preference as $p) { |
| | | $link = $label = false; |
| | | switch ($p) { |
| | |
| | | $label = jvbIcon('phone').'<span>Call</span>'; |
| | | break; |
| | | case 'email': |
| | | $link = 'mailto:'.$meta->getValue('email').'?subject='.rawurlencode('Contact from edmonton.ink').'&body='.rawurlencode('Hey, |
| | | $link = 'mailto:'.$meta->get('email').'?subject='.rawurlencode('Contact from edmonton.ink').'&body='.rawurlencode('Hey, |
| | | I found you on edmonton.ink, and I wanted to reach out!'); |
| | | $label = jvbIcon('envelope').'<span>Email</span>'; |
| | | break; |
| | |
| | | |
| | | /** |
| | | * @param int $ID |
| | | * @param MetaManager|null $meta |
| | | * @param string $type |
| | | * @return string |
| | | */ |
| | | function jvbRenderSpecialtyField(int $ID, MetaManager|null $meta = null):string |
| | | function jvbRenderSpecialtyField(int $ID, string $type = ''):string |
| | | { |
| | | if (!$meta) { |
| | | $meta = jvbGetMeta($ID); |
| | | } |
| | | $meta = match($type){ |
| | | 'post' => Meta::forPost($ID), |
| | | 'term' => Meta::forTerm($ID), |
| | | 'user' => Meta::forUser($ID), |
| | | default => false |
| | | }; |
| | | if (!$meta) { |
| | | $meta = jvbGetMeta($ID); |
| | | } |
| | | if (!$meta) { |
| | | return ''; |
| | | } |
| | | |
| | | $out = ''; |
| | | $specialties = $meta->getValue('specialties'); |
| | | $specialties = $meta->get('specialties'); |
| | | if (!empty($specialties)) { |
| | | foreach ($specialties as $specialty) { |
| | | $out .= '<li><b>'.$specialty['specialty'].'</b>'; |
| | |
| | | |
| | | /** |
| | | * @param int $ID |
| | | * @param MetaManager|null $meta |
| | | * @param string $type = '' |
| | | * @return string |
| | | */ |
| | | function jvbRenderAwardsField(int $ID, MetaManager|null $meta = null):string |
| | | function jvbRenderAwardsField(int $ID, string $type = ''):string |
| | | { |
| | | if (!$meta) { |
| | | $meta = jvbGetMeta($ID); |
| | | } |
| | | $meta = match($type){ |
| | | 'post' => Meta::forPost($ID), |
| | | 'term' => Meta::forTerm($ID), |
| | | 'user' => Meta::forUser($ID), |
| | | default => false |
| | | }; |
| | | if (!$meta) { |
| | | $meta = jvbGetMeta($ID); |
| | | } |
| | | if (!$meta) { |
| | | return ''; |
| | | } |
| | | |
| | | $out = ''; |
| | | $awards = $meta->getValue('awards'); |
| | | $awards = $meta->get('awards'); |
| | | if (!empty($awards)) { |
| | | foreach ($awards as $award) { |
| | | $out .= '<li><b>'.$award['name'].'</b>'; |
| | |
| | | |
| | | /** |
| | | * @param int $ID |
| | | * @param MetaManager|null $meta |
| | | * @param string $type |
| | | * @return string |
| | | */ |
| | | function jvbRenderReviewsField(int $ID, MetaManager|null $meta = null):string |
| | | function jvbRenderReviewsField(int $ID, string $type = ''):string |
| | | { |
| | | if (!$meta) { |
| | | $meta = jvbGetMeta($ID); |
| | | } |
| | | $meta = match($type){ |
| | | 'post' => Meta::forPost($ID), |
| | | 'term' => Meta::forTerm($ID), |
| | | 'user' => Meta::forUser($ID), |
| | | default => false |
| | | }; |
| | | if (!$meta) { |
| | | $meta = jvbGetMeta($ID); |
| | | } |
| | | if (!$meta) { |
| | | return ''; |
| | | } |
| | | |
| | | $out = ''; |
| | | $reviews = $meta->getValue('reviews'); |
| | | $reviews = $meta->get('reviews'); |
| | | if (!empty($reviews)) { |
| | | foreach ($reviews as $review) { |
| | | if ($review['review'] === '') { |
| | |
| | | return $out; |
| | | } |
| | | |
| | | function jvbGetMeta(int $ID) { |
| | | if (is_tax()) { |
| | | $type = 'term'; |
| | | } elseif (is_singular()) { |
| | | $type = 'post'; |
| | | function jvbGetMeta(int $ID):Meta|false { |
| | | if (term_exists($ID)) { |
| | | return Meta::forTerm($ID); |
| | | } elseif (get_post_status($ID)) { |
| | | return Meta::forPost($ID); |
| | | } elseif (get_userdata($ID)) { |
| | | return Meta::forUser($ID); |
| | | } else { |
| | | $type = 'user'; |
| | | } |
| | | return new JVBase\meta\MetaManager($ID, $type); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | |
| | | function jvbRenderTermList(array|bool|WP_Error $terms, string $label = '') { |
| | | function jvbRenderTermList(array|bool|WP_Error $terms, string $label = ''):string { |
| | | if (!$terms || is_wp_error($terms) || empty($terms)) { |
| | | return ''; |
| | | } |
| | |
| | | </div> |
| | | </template> |
| | | <template class="uploadItem"> |
| | | <?php |
| | | $form = new MetaForm(); |
| | | $form->renderImagePreview(); |
| | | ?> |
| | | <?= Form::renderImagePreview() ?> |
| | | </template> |
| | | <template class="restoreNotification"> |
| | | <dialog class="restore-uploads"> |
| | |
| | | |
| | | function jvbImageMeta(int|null $ID = null, string $title = '', string $alt = '', string $caption = '', array $fields = []):string |
| | | { |
| | | $form = new MetaForm(); |
| | | $dataID = ($ID) ? ['image-id' => $ID] : false; |
| | | $addID = ($ID) ? '-'.$ID : ''; |
| | | |
| | |
| | | ] |
| | | ]; |
| | | |
| | | return $form->render('image_data', null, $config, false, true); |
| | | return Form::render('image_data', null, $config, false, true); |
| | | } |
| | | |
| | | |
| | |
| | | <?php |
| | | |
| | | use JVBase\managers\Cache; |
| | | use JVBase\meta\Meta; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | |
| | | |
| | | /** |
| | | * @param int $ID |
| | | * @param JVBase\Meta\MetaManager $meta |
| | | * @param string $type |
| | | * |
| | | * @return string |
| | | */ |
| | | function jvbRenderHours(int $ID, JVBase\Meta\MetaManager $meta):string |
| | | function jvbRenderHours(int $ID, string $type = ''):string |
| | | { |
| | | $cache = Cache::for('hours_display', WEEK_IN_SECONDS)->connect('taxonomy')->connect('post')->connect('user'); |
| | | |
| | |
| | | return $cached; |
| | | } |
| | | |
| | | $meta = match($type){ |
| | | 'post' => Meta::forPost($ID), |
| | | 'term' => Meta::forTerm($ID), |
| | | 'user' => Meta::forUser($ID), |
| | | default => false |
| | | }; |
| | | if (!$meta) { |
| | | if (term_exists($ID)) { |
| | | $type = 'term'; |
| | | } elseif (get_post_status($ID)) { |
| | | $type = 'post'; |
| | | } else { |
| | | $type = 'user'; |
| | | } |
| | | $meta = new JVBase\meta\MetaManager($ID, $type); |
| | | $meta = jvbGetMeta($ID); |
| | | } |
| | | if (!$meta) { |
| | | return ''; |
| | | } |
| | | |
| | | $hours = $meta->getValue('hours'); |
| | | $byAppt = $meta->getValue('by_appointment'); |
| | | $walkins = $meta->getValue('walkins'); |
| | | $hours = $meta->get('hours'); |
| | | $byAppt = $meta->get('by_appointment'); |
| | | $walkins = $meta->get('walkins'); |
| | | |
| | | $out = ''; |
| | | |
| | |
| | | <?php |
| | | |
| | | use JVBase\meta\Form; |
| | | use JVBase\utility\Features; |
| | | use JVBase\utility\Image; |
| | | |
| | |
| | | */ |
| | | function jvbSearch(string $placeholder = 'Search...', string $id = 'search'):string |
| | | { |
| | | $id = sanitize_title($id); |
| | | return sprintf( |
| | | '<div class="search-container row start nowrap"> |
| | | <input type="search" id="%s" placeholder="%s"> |
| | | <button |
| | | title="Clear Search" |
| | | type="button" |
| | | class="clear-search" |
| | | aria-label="Clear search" |
| | | onclick="this.previousElementSibling.value = \'\'; this.previousElementSibling.focus();" |
| | | >%s</button> |
| | | <button type="button" title="Search" class="toggle search" aria-label="Toggles search input visually" onclick="this.parentNode.classList.toggle(\'open\');this.previousElementSibling.previousElementSibling.focus();">%s</button> |
| | | </div>', |
| | | $id, |
| | | $placeholder, |
| | | jvbIcon('x', ['title'=> 'Clear Search']), |
| | | jvbIcon('magnifying-glass') |
| | | ); |
| | | return Form::search($placeholder, $id); |
| | | } |
| | | |
| | | |
| | |
| | | return $out; |
| | | } |
| | | |
| | | function jvbRenderProgressBar(string $inside ='', $top = false, $icon = true) |
| | | function jvbRenderProgressBar(string $inside ='', $top = false, $icon = true, $return = false):string |
| | | { |
| | | |
| | | $top = $top ? ' abs top' : ''; |
| | | ?> |
| | | <div class="progress<?=$top?>"> |
| | | $bar = sprintf( |
| | | '<div class="progress%s"> |
| | | <div class="bar"> |
| | | <div class="fill"></div> |
| | | </div> |
| | | <div class="row btw"> |
| | | <?php if ($icon) { ?> |
| | | <i class="icon"></i> |
| | | <?php } ?> |
| | | %s |
| | | <div class="details"> |
| | | <?=$inside?> |
| | | %s |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <?php |
| | | </div>', |
| | | $top, |
| | | ($icon) ? '<i class="icon"></i>': '', |
| | | $inside |
| | | ); |
| | | if (!$return) { |
| | | echo $bar; |
| | | } |
| | | return $bar; |
| | | } |
| | | |
| | | function jvbFormStatus(string $message = '') { |
| | |
| | | <?php |
| | | |
| | | namespace JVBase\managers; |
| | | namespace JVBase\importers; |
| | | |
| | | use WP_Error; |
| | | |
| | |
| | | * |
| | | * Imports sales/treatment data from JaneApp CSV exports and updates referral tracking |
| | | */ |
| | | class JaneSalesImporter |
| | | class JaneAppSalesImporter |
| | | { |
| | | protected $wpdb; |
| | | protected string $jane_clients_table; |
| | |
| | | <?php |
| | | namespace JVBase\integrations; |
| | | |
| | | use JVBase\meta\MetaManager; |
| | | use WP_Error; |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | |
| | | */ |
| | | namespace JVBase\integrations; |
| | | |
| | | use JVBase\meta\MetaManager; |
| | | use Exception; |
| | | use JVBase\meta\Meta; |
| | | use WP_Error; |
| | | use WP_Post; |
| | | |
| | |
| | | private function createFacebookEvent(array $data): array |
| | | { |
| | | $post = get_post($data['post_id']); |
| | | $meta = new MetaManager($post->ID, 'post'); |
| | | $meta = Meta::forPost($post->ID); |
| | | |
| | | $event_data = [ |
| | | 'name' => $post->post_title, |
| | | 'description' => $this->formatPostContent($post), |
| | | 'start_time' => $meta->getValue('event_start_date'), |
| | | 'end_time' => $meta->getValue('event_end_date'), |
| | | 'start_time' => $meta->get('event_start_date'), |
| | | 'end_time' => $meta->get('event_end_date'), |
| | | 'access_token' => $this->page_access_token |
| | | ]; |
| | | |
| | | // Add location if available |
| | | $location = $meta->getValue('event_location'); |
| | | $location = $meta->get('event_location'); |
| | | if ($location) { |
| | | $event_data['location'] = $location; |
| | | } |
| | |
| | | <?php |
| | | namespace JVBase\integrations; |
| | | |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\Meta; |
| | | use WP_Error; |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | |
| | | } |
| | | |
| | | $postID = $data['post_id']; |
| | | $meta = new MetaManager($postID, 'post'); |
| | | $meta = Meta::forPost($postID); |
| | | $fields = [ |
| | | 'start_date', |
| | | 'end_date', |
| | |
| | | $result = $this->updatePost($fields["_{$this->service_name}_item_id"], $data); |
| | | } else { |
| | | $result = $this->createPost($data); |
| | | $meta->updateValue("_{$this->service_name}_item_id", $result['name']); |
| | | $meta->set("_{$this->service_name}_item_id", $result['name']); |
| | | } |
| | | |
| | | return [ |
| | |
| | | } |
| | | |
| | | $postID = $data['post_id']; |
| | | $meta = new MetaManager($postID, 'post'); |
| | | $meta = Meta::forPost($postID); |
| | | $fields = [ |
| | | 'post_excerpt', |
| | | 'post_title', |
| | |
| | | $result = $this->updatePost($fields["_{$this->service_name}_item_id"], $data); |
| | | } else { |
| | | $result = $this->createPost($data); |
| | | $meta->updateValue("_{$this->service_name}_item_id", $result['name']); |
| | | $meta->set("_{$this->service_name}_item_id", $result['name']); |
| | | } |
| | | |
| | | return [ |
| | |
| | | } |
| | | |
| | | $postID = $data['post_id']; |
| | | $meta = new MetaManager($postID, 'post'); |
| | | $meta = Meta::forPost($postID); |
| | | $fields = [ |
| | | 'post_excerpt', |
| | | 'post_title', |
| | |
| | | $result = $this->updatePost($fields["_{$this->service_name}_item_id"], $data); |
| | | } else { |
| | | $result = $this->createPost($data); |
| | | $meta->updateValue("_{$this->service_name}_item_id", $result['name']); |
| | | $meta->set("_{$this->service_name}_item_id", $result['name']); |
| | | } |
| | | |
| | | return [ |
| | |
| | | protected function collectMenu(array $menu_items): array |
| | | { |
| | | |
| | | $defaultMeta = new MetaManager($this->userID, 'integrations'); |
| | | $defaultMeta = Meta::forOptions($this->userID.'_integrations'); |
| | | $defaults = ['menu_name', 'menu_description', 'default_section', 'cuisines', 'source_url', 'language', 'default_currency']; |
| | | $defaults = $defaultMeta->getAll($defaults); |
| | | |
| | |
| | | |
| | | protected function buildMenuItem(\WP_Post $item, array $defaults):array |
| | | { |
| | | $meta = new MetaManager($item->ID, 'post'); |
| | | $meta = Meta::forPost($item->ID); |
| | | $fields = $this->mappedMenuFields($item->post_type); |
| | | $values = $meta->getAll(array_values($fields)); |
| | | |
| | |
| | | |
| | | protected function getMenuSectionsOrder(array $sections_map):array |
| | | { |
| | | $optionsMeta = new MetaManager(null, 'options'); |
| | | $sectionOrder = $optionsMeta->getValue('menu_section_order'); |
| | | $optionsMeta = Meta::forOptions('options'); |
| | | $sectionOrder = $optionsMeta->get('menu_section_order'); |
| | | |
| | | // Build final GMB menu structure |
| | | $ordered = []; |
| | |
| | | |
| | | // Collect cuisines from individual items if specified |
| | | foreach ($menu_items as $item) { |
| | | $meta = new MetaManager($item->ID, 'post'); |
| | | $item_cuisines = $meta->getValue('cuisines'); |
| | | $meta = Meta::forPost($item->ID); |
| | | $item_cuisines = $meta->get('cuisines'); |
| | | |
| | | if (!empty($item_cuisines)) { |
| | | $item_cuisines = is_array($item_cuisines) ? |
| | |
| | | <?php |
| | | namespace JVBase\integrations; |
| | | |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\Meta; |
| | | use Exception; |
| | | use WP_Error; |
| | | use WP_REST_Request; |
| | |
| | | $post = get_post($post_id); |
| | | if (!$post) continue; |
| | | |
| | | $meta = new MetaManager($post_id, 'post'); |
| | | $meta = Meta::forPost($post_id); |
| | | $field_map = $this->field_mappings[$post->post_type] ?? []; |
| | | |
| | | // Prepare product data for Helcim |
| | |
| | | 'description' => $post->post_content, |
| | | 'productCode' => get_post_meta($post_id, BASE . '_helcim_product_code', true) ?: 'WP-' . $post_id, |
| | | 'type' => $content_type, |
| | | 'price' => floatval($meta->getValue('price')) * 100, // Convert to cents |
| | | 'taxable' => (bool)$meta->getValue('is_taxable'), |
| | | 'price' => floatval($meta->get('price')) * 100, // Convert to cents |
| | | 'taxable' => (bool)$meta->get('is_taxable'), |
| | | ]; |
| | | |
| | | // Handle variations |
| | | $variations = $meta->getValue('product_variations'); |
| | | $variations = $meta->get('product_variations'); |
| | | if (!empty($variations)) { |
| | | $product_data['variations'] = $this->prepareVariations($variations); |
| | | } |
| | |
| | | |
| | | if ($post_id) { |
| | | // Update meta data |
| | | $meta = new MetaManager($post_id, 'post'); |
| | | $meta = Meta::forPost($post_id); |
| | | $meta->setAll([ |
| | | 'price' => $product['price'] / 100, // Convert from cents |
| | | '_helcim_product_id' => $product['productId'], |
| | |
| | | $post_id = intval($item['id'] ?? 0); |
| | | if (!$post_id) continue; |
| | | |
| | | $meta = new MetaManager($post_id, 'post'); |
| | | $price = floatval($meta->getValue('price')); |
| | | $meta = Meta::forPost($post_id); |
| | | $price = floatval($meta->get('price')); |
| | | $quantity = intval($item['quantity'] ?? 1); |
| | | |
| | | $total += ($price * $quantity * 100); // Convert to cents |
| | |
| | | if (!$post_id) continue; |
| | | |
| | | $post = get_post($post_id); |
| | | $meta = new MetaManager($post_id, 'post'); |
| | | $meta = Meta::forPost($post_id); |
| | | |
| | | $line_items[] = [ |
| | | 'description' => $post->post_title, |
| | | 'quantity' => intval($item['quantity'] ?? 1), |
| | | 'price' => floatval($meta->getValue('price')) * 100, |
| | | 'price' => floatval($meta->get('price')) * 100, |
| | | 'productCode' => get_post_meta($post_id, BASE . '_helcim_product_code', true) ?: 'WP-' . $post_id |
| | | ]; |
| | | } |
| | |
| | | |
| | | use Exception; |
| | | use JVBase\managers\Cache; |
| | | use JVBase\managers\UploadManager; |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\Form; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\managers\ErrorHandler; |
| | | use WP_Error; |
| | | use WP_Post; |
| | |
| | | */ |
| | | protected function mapFieldsToService(int $postID, array $mapping): array |
| | | { |
| | | $meta_manager = new MetaManager($postID, 'post'); |
| | | $post = get_post($postID); |
| | | $meta_manager = Meta::forPost($postID); |
| | | $service_data = []; |
| | | |
| | | foreach ($mapping as $wp_field => $service_field) { |
| | | $value = null; |
| | | |
| | | // Check if it's a post field |
| | | if (property_exists($post, $wp_field)) { |
| | | $value = $post->$wp_field; |
| | | } else { |
| | | // It's a meta field |
| | | $value = $meta_manager->getValue($wp_field); |
| | | } |
| | | $value = $meta_manager->get($wp_field); |
| | | |
| | | if ($value !== null && $value !== '') { |
| | | $this->setNestedValue($service_data, $service_field, $value); |
| | |
| | | |
| | | protected function getSyncFields(int $postID, string $type, array $additional = []):array |
| | | { |
| | | $meta = new MetaManager($postID, $type); |
| | | $meta = new Meta($postID, $type); |
| | | $fieldsToCheck = [ |
| | | 'share_to_' . $this->service_name, |
| | | '_keep_synced_' . $this->service_name, |
| | |
| | | return ''; |
| | | } |
| | | |
| | | $meta = new MetaManager($this->userID, 'integrations'); |
| | | $meta = Meta::forOptions($this->userID.'_integrations'); |
| | | $is_connected = $this->isSetUp(); |
| | | $credentials = $this->getCredentials(); |
| | | |
| | |
| | | $config['value'] = $credentials[$name]??''; |
| | | $config['autocomplete'] = 'off'; |
| | | $config['base'] = $this->service_name.'_'; |
| | | $meta->render('form', $name, $config); |
| | | Form::render($name, null, $config); |
| | | } |
| | | } |
| | | if ($this->handleWebhooks) { |
| | |
| | | $config['value'] = $credentials[$name]??''; |
| | | $config['base'] = $this->service_name.'_'; |
| | | $config['autocomplete'] = 'off'; |
| | | $meta->render('form', $name, $config); |
| | | Form::render($name,null, $config); |
| | | } |
| | | ?> |
| | | </details> |
| | |
| | | if (empty($types)) { |
| | | return; |
| | | } |
| | | $meta = new MetaManager($this->userID, 'integrations'); |
| | | $meta = Meta::forOptions($this->userID.'_integrations'); |
| | | ?> |
| | | <form> |
| | | <h1><?= $this->title?> Defaults:</h1> |
| | |
| | | |
| | | $config['base'] = $this->service_name.'_'; |
| | | $config['autocomplete'] = 'off'; |
| | | $meta->render('form', $name, $config); |
| | | echo Form::render($name, null, $config); |
| | | } |
| | | foreach ($this->syncPostTypes as $type) { |
| | | $config = JVB_CONTENT[$type]; |
| | |
| | | $c['hint'] = $c['description']; |
| | | unset($c['description']); |
| | | } |
| | | $meta->render('form', $name, $c); |
| | | echo Form::render($name, null, $c); |
| | | } |
| | | ?> |
| | | </details> |
| | |
| | | <?php |
| | | namespace JVBase\integrations; |
| | | |
| | | use JVBase\meta\MetaForm; |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\Form; |
| | | use JVBase\meta\Meta; |
| | | use Exception; |
| | | use JVBase\registry\PostTypeRegistrar; |
| | | use WP_Error; |
| | |
| | | if (is_singular(BASE.'dash') || is_post_type_archive(BASE.'dash')) { |
| | | return $actions; |
| | | } |
| | | $meta = new MetaForm(); |
| | | $form = '<aside id="cart" class="right main"> |
| | | <form id="checkout" data-form-id="checkout" data-save="checkout">'; |
| | | |
| | |
| | | 'description' => 'Securely checkout with your name, email, and payments processed by Square.', |
| | | 'content' => '<div class="checkout-section"> |
| | | <h3>Customer Information</h3> |
| | | '.$meta->return('cart_name', null, [ |
| | | '.Form::render('cart_name', null, [ |
| | | 'type' => 'text', |
| | | 'label' => 'Your Name', |
| | | 'required' => true, |
| | | 'autocomplete' => 'name' |
| | | ]). |
| | | $meta->return('cart_email', null, [ |
| | | Form::render('cart_email', null, [ |
| | | 'type' => 'email', |
| | | 'label' => 'Your Email', |
| | | 'required' => true, |
| | | 'autocomplete'=> 'email', |
| | | ]). |
| | | $meta->return('cart_phone', null, [ |
| | | Form::render('cart_phone', null, [ |
| | | 'type' => 'tel', |
| | | 'label' => 'Your Phone', |
| | | 'required' => true, |
| | | 'autocomplete'=> 'phone' |
| | | ]).' |
| | | <h3>Pickup Details</h3>'. |
| | | $meta->return('pickup_time', null, [ |
| | | Form::render('pickup_time', null, [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Pickup Type', |
| | | 'min' => '11:00', |
| | | 'max' => '20:00', |
| | | 'required' => true, |
| | | ]). |
| | | $meta->return('special_instructions', null, [ |
| | | Form::render('special_instructions', null, [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Special Instructions', |
| | | 'quill' => true, |
| | |
| | | return new WP_Error('post_not_found', "Post $postID not found"); |
| | | } |
| | | |
| | | $meta = new MetaManager($postID, 'post'); |
| | | $meta = Meta::forPost($postID); |
| | | $post_type = get_post_type($postID); |
| | | |
| | | // Get existing Square catalog ID if it exists |
| | |
| | | } |
| | | |
| | | // Add variations |
| | | $variations = $meta->getValue('product_variations'); |
| | | $variations = $meta->get('product_variations'); |
| | | if (empty($variations)) { |
| | | // Create default variation if none exist |
| | | $price = floatval($meta->getValue('price') ?: 0); |
| | | $price = floatval($meta->get('price') ?: 0); |
| | | $catalog_object['item_data']['variations'][] = [ |
| | | 'type' => 'ITEM_VARIATION', |
| | | 'id' => $existing_square_id ? null : '#'.BASE.'menu_item_' . $postID . '_var_default', |
| | |
| | | } |
| | | |
| | | // Add modifiers if they exist |
| | | $modifiers = $meta->getValue('modifiers'); |
| | | $modifiers = $meta->get('modifiers'); |
| | | if (!empty($modifiers)) { |
| | | $modifier_ids = []; |
| | | foreach ($modifiers as $modifier) { |
| | |
| | | } |
| | | |
| | | // Add tax settings |
| | | $tax_ids = $meta->getValue('tax_ids'); |
| | | $tax_ids = $meta->get('tax_ids'); |
| | | if (!empty($tax_ids)) { |
| | | $catalog_object['item_data']['tax_ids'] = $tax_ids; |
| | | } |
| | |
| | | |
| | | if ($wp_order_id) { |
| | | // Update the post meta |
| | | $meta = new MetaManager($wp_order_id, 'post'); |
| | | $meta = Meta::forPost($wp_order_id); |
| | | $updates = [ |
| | | 'status' => $state, |
| | | 'updated_at' => current_time('mysql') |
| | |
| | | */ |
| | | private function mapSquareFieldsToWordPress(int $post_id, array $item): void |
| | | { |
| | | $meta = new MetaManager($post_id, 'post'); |
| | | $meta = Meta::forPost($post_id); |
| | | $field_map = $this->getFieldMapping(get_post_type($post_id)); |
| | | |
| | | $values_to_save = []; |
| | |
| | | } |
| | | |
| | | // Save all order meta |
| | | $meta = new MetaManager($order_post_id, 'post'); |
| | | $meta = Meta::forPost($order_post_id); |
| | | $fields = $this->getSquarePostConfig('_sq_orders')['fields']; |
| | | unset($fields['post_title']); |
| | | $meta->setFieldConfig($fields); |
| | | |
| | | $meta->setAll([ |
| | | 'square_order_id' => $order_data['square_order_id'], |
| | |
| | | add_action('save_post', [self::class, 'onPostChange'], 10, 2); |
| | | add_action('delete_post', [self::class, 'onPostDelete']); |
| | | |
| | | // Post meta updates, now handled via MetaManager.php? |
| | | // Post meta updates, now handled via Meta.php? |
| | | // add_action('updated_post_meta', [self::class, 'onPostMetaChange'], 10, 2); |
| | | // add_action('added_post_meta', [self::class, 'onPostMetaChange'], 10, 2); |
| | | // add_action('deleted_post_meta', [self::class, 'onPostMetaDelete'], 10, 2); |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\managers; |
| | | |
| | | use Exception; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Custom Table Helper |
| | | * |
| | | * Provides consistent interface for CRUD operations on custom tables |
| | | * Used by routes that interact with custom tables defined in CheckCustomTables.php |
| | | * |
| | | * @example |
| | | * $table = new CustomTable('favourites'); |
| | | * $result = $table->insert(['user_id' => 1, 'type' => 'tattoo', 'target_id' => 123]); |
| | | */ |
| | | class CustomTable |
| | | { |
| | | protected \wpdb $wpdb; |
| | | protected string $tableName; |
| | | protected string $fullTableName; |
| | | protected bool $useTransactions; |
| | | |
| | | /** @var array<string, self> Instance cache for fluent interface */ |
| | | protected static array $instances = []; |
| | | |
| | | /** |
| | | * Fluent factory method |
| | | * |
| | | * @param string $tableName Table name without prefix/BASE |
| | | * @return self |
| | | * |
| | | * @example CustomTable::for('favourites')->insert($data); |
| | | */ |
| | | public static function for(string $tableName): self |
| | | { |
| | | if (!isset(self::$instances[$tableName])) { |
| | | self::$instances[$tableName] = new self($tableName); |
| | | } |
| | | |
| | | return self::$instances[$tableName]; |
| | | } |
| | | |
| | | /** |
| | | * Clear instance cache (useful for testing) |
| | | */ |
| | | public static function clearCache(): void |
| | | { |
| | | self::$instances = []; |
| | | } |
| | | |
| | | /** |
| | | * @param string $tableName Table name without prefix/BASE (e.g., 'favourites', 'notifications') |
| | | * @param bool $useTransactions Whether to auto-wrap operations in transactions |
| | | */ |
| | | public function __construct(string $tableName, bool $useTransactions = false) |
| | | { |
| | | global $wpdb; |
| | | $this->wpdb = $wpdb; |
| | | $this->tableName = $tableName; |
| | | $this->fullTableName = $wpdb->prefix . BASE . $tableName; |
| | | $this->useTransactions = $useTransactions; |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // FLUENT QUERY BUILDER |
| | | // ========================================================================= |
| | | |
| | | /** @var array Query builder state */ |
| | | protected array $builder = []; |
| | | |
| | | /** |
| | | * Start a fluent query - set WHERE conditions |
| | | * |
| | | * @param array $conditions Associative array of column => value |
| | | * @return self |
| | | * |
| | | * @example CustomTable::for('favourites')->where(['user_id' => 1])->get(); |
| | | */ |
| | | public function where(array $conditions): self |
| | | { |
| | | $this->builder['where'] = $conditions; |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Set ORDER BY |
| | | * |
| | | * @param string $column Column to order by |
| | | * @param string $direction ASC or DESC |
| | | * @return self |
| | | */ |
| | | public function orderBy(string $column, string $direction = 'DESC'): self |
| | | { |
| | | $this->builder['orderby'] = $column; |
| | | $this->builder['order'] = strtoupper($direction); |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Set LIMIT |
| | | * |
| | | * @param int $limit Number of records |
| | | * @param int $offset Optional offset |
| | | * @return self |
| | | */ |
| | | public function limit(int $limit, int $offset = 0): self |
| | | { |
| | | $this->builder['limit'] = $limit; |
| | | $this->builder['offset'] = $offset; |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Execute the built query and get results |
| | | * |
| | | * @param string $output OBJECT, ARRAY_A, or ARRAY_N |
| | | * @return array |
| | | */ |
| | | public function getResults(string $output = OBJECT): array |
| | | { |
| | | $results = $this->getMany($this->builder, $output); |
| | | $this->resetBuilder(); |
| | | return $results; |
| | | } |
| | | |
| | | /** |
| | | * Execute the built query and get first result |
| | | * |
| | | * @param string $output OBJECT, ARRAY_A, or ARRAY_N |
| | | * @return object|array|null |
| | | */ |
| | | public function first(string $output = OBJECT): object|array|null |
| | | { |
| | | $this->builder['limit'] = 1; |
| | | $results = $this->getMany($this->builder, $output); |
| | | $this->resetBuilder(); |
| | | return $results[0] ?? null; |
| | | } |
| | | |
| | | /** |
| | | * Count records with current builder state |
| | | * |
| | | * @return int |
| | | */ |
| | | public function countResults(): int |
| | | { |
| | | $where = $this->builder['where'] ?? []; |
| | | $count = $this->count($where); |
| | | $this->resetBuilder(); |
| | | return $count; |
| | | } |
| | | |
| | | /** |
| | | * Check if any records exist with current builder state |
| | | * |
| | | * @return bool |
| | | */ |
| | | public function existsInQuery(): bool |
| | | { |
| | | return $this->countResults() > 0; |
| | | } |
| | | |
| | | /** |
| | | * Delete records matching current builder state |
| | | * |
| | | * @return int|false Number of deleted rows |
| | | */ |
| | | public function deleteResults(): int|false |
| | | { |
| | | $where = $this->builder['where'] ?? []; |
| | | $result = $this->delete($where); |
| | | $this->resetBuilder(); |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * Update records matching current builder state |
| | | * |
| | | * @param array $data Data to update |
| | | * @return int|false Number of updated rows |
| | | */ |
| | | public function updateResults(array $data): int|false |
| | | { |
| | | $where = $this->builder['where'] ?? []; |
| | | $result = $this->update($data, $where); |
| | | $this->resetBuilder(); |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * Reset query builder state |
| | | */ |
| | | protected function resetBuilder(): void |
| | | { |
| | | $this->builder = []; |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // CREATE OPERATIONS |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Insert a single record |
| | | * |
| | | * @param array $data Associative array of column => value |
| | | * @param array|null $format Optional array of format strings (%d, %s, %f) |
| | | * @return int|false Insert ID on success, false on failure |
| | | * |
| | | * @example |
| | | * $id = $table->insert([ |
| | | * 'user_id' => 1, |
| | | * 'type' => 'tattoo', |
| | | * 'target_id' => 123, |
| | | * 'date_added' => current_time('mysql') |
| | | * ]); |
| | | */ |
| | | public function insert(array $data, ?array $format = null): int|false |
| | | { |
| | | // Auto-add created_at if column exists and not provided |
| | | if (!isset($data['created_at']) && $this->hasColumn('created_at')) { |
| | | $data['created_at'] = current_time('mysql'); |
| | | } |
| | | |
| | | $result = $this->wpdb->insert( |
| | | $this->fullTableName, |
| | | $data, |
| | | $format |
| | | ); |
| | | |
| | | if ($result === false) { |
| | | $this->logError('insert', $data); |
| | | return false; |
| | | } |
| | | |
| | | return $this->wpdb->insert_id; |
| | | } |
| | | |
| | | /** |
| | | * Alias for insert() - more semantic for fluent interface |
| | | * |
| | | * @param array $data Data to insert |
| | | * @return int|false Insert ID on success |
| | | * |
| | | * @example CustomTable::for('favourites')->create(['user_id' => 1]); |
| | | */ |
| | | public function create(array $data): int|false |
| | | { |
| | | return $this->insert($data); |
| | | } |
| | | |
| | | /** |
| | | * Find or create a record |
| | | * |
| | | * @param array $searchData Data to search for |
| | | * @param array $createData Optional additional data for creation |
| | | * @return array ['id' => int, 'created' => bool, 'record' => object] |
| | | * |
| | | * @example |
| | | * $result = CustomTable::for('favourites')->findOrCreate( |
| | | * ['user_id' => 1, 'target_id' => 123], |
| | | * ['type' => 'tattoo'] |
| | | * ); |
| | | * // Returns: ['id' => 456, 'created' => false, 'record' => object] |
| | | */ |
| | | public function findOrCreate(array $searchData, array $createData = []): array |
| | | { |
| | | $record = $this->get($searchData); |
| | | |
| | | if ($record) { |
| | | return [ |
| | | 'id' => $record->id ?? 0, |
| | | 'created' => false, |
| | | 'record' => $record |
| | | ]; |
| | | } |
| | | |
| | | $data = array_merge($searchData, $createData); |
| | | $id = $this->insert($data); |
| | | |
| | | return [ |
| | | 'id' => $id, |
| | | 'created' => true, |
| | | 'record' => $this->get(['id' => $id]) |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Bulk insert multiple records efficiently |
| | | * |
| | | * @param array $rows Array of associative arrays |
| | | * @param array $columns Column names (must be same for all rows) |
| | | * @return int|false Number of rows inserted, false on failure |
| | | * |
| | | * @example |
| | | * $count = $table->bulkInsert([ |
| | | * ['user_id' => 1, 'type' => 'tattoo', 'target_id' => 123], |
| | | * ['user_id' => 1, 'type' => 'tattoo', 'target_id' => 456], |
| | | * ], ['user_id', 'type', 'target_id']); |
| | | */ |
| | | public function bulkInsert(array $rows, array $columns): int|false |
| | | { |
| | | if (empty($rows)) { |
| | | return 0; |
| | | } |
| | | |
| | | // Auto-add created_at if column exists |
| | | if ($this->hasColumn('created_at') && !in_array('created_at', $columns)) { |
| | | $columns[] = 'created_at'; |
| | | $now = current_time('mysql'); |
| | | foreach ($rows as &$row) { |
| | | $row['created_at'] = $now; |
| | | } |
| | | } |
| | | |
| | | $placeholders = []; |
| | | $values = []; |
| | | |
| | | foreach ($rows as $row) { |
| | | $row_placeholders = []; |
| | | foreach ($columns as $column) { |
| | | $value = $row[$column] ?? null; |
| | | $values[] = $value; |
| | | $row_placeholders[] = $this->getPlaceholder($value); |
| | | } |
| | | $placeholders[] = "(" . implode(',', $row_placeholders) . ")"; |
| | | } |
| | | |
| | | $columns_escaped = array_map(function($col) { |
| | | return "`{$col}`"; |
| | | }, $columns); |
| | | |
| | | $query = "INSERT INTO {$this->fullTableName} |
| | | (" . implode(',', $columns_escaped) . ") |
| | | VALUES " . implode(',', $placeholders); |
| | | |
| | | $result = $this->wpdb->query($this->wpdb->prepare($query, $values)); |
| | | |
| | | if ($result === false) { |
| | | $this->logError('bulkInsert', ['rows' => count($rows)]); |
| | | return false; |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // READ OPERATIONS |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Get a single record |
| | | * |
| | | * @param array $where Associative array of column => value conditions |
| | | * @param string $output OBJECT, ARRAY_A, or ARRAY_N |
| | | * @return object|array|null |
| | | * |
| | | * @example |
| | | * $fav = $table->get(['user_id' => 1, 'target_id' => 123]); |
| | | */ |
| | | public function get(array $where, string $output = OBJECT): object|array|null |
| | | { |
| | | $query = "SELECT * FROM {$this->fullTableName} WHERE " . $this->buildWhereClause($where); |
| | | $values = array_values($where); |
| | | |
| | | return $this->wpdb->get_row($this->wpdb->prepare($query, $values), $output); |
| | | } |
| | | |
| | | /** |
| | | * Get multiple records |
| | | * |
| | | * @param array $args Query arguments: where, orderby, order, limit, offset |
| | | * @param string $output OBJECT, ARRAY_A, or ARRAY_N |
| | | * @return array |
| | | * |
| | | * @example |
| | | * $favs = $table->getMany([ |
| | | * 'where' => ['user_id' => 1], |
| | | * 'orderby' => 'date_added', |
| | | * 'order' => 'DESC', |
| | | * 'limit' => 20 |
| | | * ]); |
| | | */ |
| | | public function getMany(array $args = [], string $output = OBJECT): array |
| | | { |
| | | $query = "SELECT * FROM {$this->fullTableName}"; |
| | | $values = []; |
| | | |
| | | // WHERE clause |
| | | if (!empty($args['where'])) { |
| | | $query .= " WHERE " . $this->buildWhereClause($args['where']); |
| | | $values = array_merge($values, array_values($args['where'])); |
| | | } |
| | | |
| | | // ORDER BY |
| | | if (!empty($args['orderby'])) { |
| | | $orderby = sanitize_sql_orderby($args['orderby']); |
| | | $order = (!empty($args['order']) && strtoupper($args['order']) === 'ASC') ? 'ASC' : 'DESC'; |
| | | $query .= " ORDER BY {$orderby} {$order}"; |
| | | } |
| | | |
| | | // LIMIT |
| | | if (!empty($args['limit'])) { |
| | | $limit = absint($args['limit']); |
| | | $offset = !empty($args['offset']) ? absint($args['offset']) : 0; |
| | | $query .= " LIMIT {$offset}, {$limit}"; |
| | | } |
| | | |
| | | if (empty($values)) { |
| | | return $this->wpdb->get_results($query, $output); |
| | | } |
| | | |
| | | return $this->wpdb->get_results($this->wpdb->prepare($query, $values), $output); |
| | | } |
| | | |
| | | /** |
| | | * Count records |
| | | * |
| | | * @param array $where Associative array of column => value conditions |
| | | * @return int |
| | | */ |
| | | public function count(array $where = []): int |
| | | { |
| | | $query = "SELECT COUNT(*) FROM {$this->fullTableName}"; |
| | | $values = []; |
| | | |
| | | if (!empty($where)) { |
| | | $query .= " WHERE " . $this->buildWhereClause($where); |
| | | $values = array_values($where); |
| | | } |
| | | |
| | | if (empty($values)) { |
| | | return (int) $this->wpdb->get_var($query); |
| | | } |
| | | |
| | | return (int) $this->wpdb->get_var($this->wpdb->prepare($query, $values)); |
| | | } |
| | | |
| | | /** |
| | | * Check if record exists |
| | | * |
| | | * @param array $where Associative array of column => value conditions |
| | | * @return bool |
| | | */ |
| | | public function exists(array $where): bool |
| | | { |
| | | return $this->count($where) > 0; |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // UPDATE OPERATIONS |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Update records |
| | | * |
| | | * @param array $data Data to update (column => value) |
| | | * @param array $where Where conditions (column => value) |
| | | * @param array|null $format Optional format for data |
| | | * @param array|null $where_format Optional format for where |
| | | * @return int|false Number of rows updated, false on failure |
| | | * |
| | | * @example |
| | | * $updated = $table->update( |
| | | * ['status' => 'read'], |
| | | * ['id' => 123, 'user_id' => 1] |
| | | * ); |
| | | */ |
| | | public function update(array $data, array $where, ?array $format = null, ?array $where_format = null): int|false |
| | | { |
| | | // Auto-update updated_at if column exists and not provided |
| | | if (!isset($data['updated_at']) && $this->hasColumn('updated_at')) { |
| | | $data['updated_at'] = current_time('mysql'); |
| | | } |
| | | |
| | | $result = $this->wpdb->update( |
| | | $this->fullTableName, |
| | | $data, |
| | | $where, |
| | | $format, |
| | | $where_format |
| | | ); |
| | | |
| | | if ($result === false) { |
| | | $this->logError('update', ['data' => $data, 'where' => $where]); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // DELETE OPERATIONS |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Delete records |
| | | * |
| | | * @param array $where Where conditions (column => value) |
| | | * @param array|null $where_format Optional format for where |
| | | * @return int|false Number of rows deleted, false on failure |
| | | * |
| | | * @example |
| | | * $deleted = $table->delete(['id' => 123]); |
| | | */ |
| | | public function delete(array $where, ?array $where_format = null): int|false |
| | | { |
| | | $result = $this->wpdb->delete( |
| | | $this->fullTableName, |
| | | $where, |
| | | $where_format |
| | | ); |
| | | |
| | | if ($result === false) { |
| | | $this->logError('delete', ['where' => $where]); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // RAW QUERY OPERATIONS |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Execute a raw query with automatic table name substitution |
| | | * |
| | | * @param string $query SQL query (use {table} as placeholder) |
| | | * @param array $values Values for prepare() |
| | | * @return mixed Query result |
| | | * |
| | | * @example |
| | | * $results = $table->query( |
| | | * "SELECT * FROM {table} WHERE user_id = %d AND status IN (%s, %s)", |
| | | * [1, 'pending', 'active'] |
| | | * ); |
| | | */ |
| | | public function query(string $query, array $values = []): mixed |
| | | { |
| | | $query = str_replace('{table}', $this->fullTableName, $query); |
| | | |
| | | if (empty($values)) { |
| | | return $this->wpdb->query($query); |
| | | } |
| | | |
| | | return $this->wpdb->query($this->wpdb->prepare($query, $values)); |
| | | } |
| | | |
| | | /** |
| | | * Get results from raw query |
| | | * |
| | | * @param string $query SQL query (use {table} as placeholder) |
| | | * @param array $values Values for prepare() |
| | | * @param string $output OBJECT, ARRAY_A, or ARRAY_N |
| | | * @return array |
| | | */ |
| | | public function queryResults(string $query, array $values = [], string $output = OBJECT): array |
| | | { |
| | | $query = str_replace('{table}', $this->fullTableName, $query); |
| | | |
| | | if (empty($values)) { |
| | | return $this->wpdb->get_results($query, $output); |
| | | } |
| | | |
| | | return $this->wpdb->get_results($this->wpdb->prepare($query, $values), $output); |
| | | } |
| | | |
| | | /** |
| | | * Get single value from query |
| | | * |
| | | * @param string $query SQL query (use {table} as placeholder) |
| | | * @param array $values Values for prepare() |
| | | * @return mixed |
| | | */ |
| | | public function queryVar(string $query, array $values = []): mixed |
| | | { |
| | | $query = str_replace('{table}', $this->fullTableName, $query); |
| | | |
| | | if (empty($values)) { |
| | | return $this->wpdb->get_var($query); |
| | | } |
| | | |
| | | return $this->wpdb->get_var($this->wpdb->prepare($query, $values)); |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // TRANSACTION HELPERS |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Start a transaction |
| | | */ |
| | | public function startTransaction(): void |
| | | { |
| | | $this->wpdb->query('START TRANSACTION'); |
| | | } |
| | | |
| | | /** |
| | | * Commit a transaction |
| | | */ |
| | | public function commit(): void |
| | | { |
| | | $this->wpdb->query('COMMIT'); |
| | | } |
| | | |
| | | /** |
| | | * Rollback a transaction |
| | | */ |
| | | public function rollback(): void |
| | | { |
| | | $this->wpdb->query('ROLLBACK'); |
| | | } |
| | | |
| | | /** |
| | | * Execute callback within a transaction |
| | | * |
| | | * @param callable $callback Function to execute |
| | | * @return mixed Returns callback result |
| | | * @throws Exception Rolls back on exception |
| | | * |
| | | * @example |
| | | * $result = $table->transaction(function() use ($table) { |
| | | * $table->insert(['user_id' => 1, ...]); |
| | | * $table->update(['status' => 'active'], ['id' => 123]); |
| | | * return true; |
| | | * }); |
| | | */ |
| | | public function transaction(callable $callback): mixed |
| | | { |
| | | $this->startTransaction(); |
| | | |
| | | try { |
| | | $result = $callback($this); |
| | | $this->commit(); |
| | | return $result; |
| | | } catch (Exception $e) { |
| | | $this->rollback(); |
| | | $this->logError('transaction', ['error' => $e->getMessage()]); |
| | | throw $e; |
| | | } |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // UTILITY METHODS |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Get the full table name (with prefix) |
| | | */ |
| | | public function getFullTableName(): string |
| | | { |
| | | return $this->fullTableName; |
| | | } |
| | | |
| | | /** |
| | | * Get last insert ID |
| | | */ |
| | | public function getInsertId(): int |
| | | { |
| | | return $this->wpdb->insert_id; |
| | | } |
| | | |
| | | /** |
| | | * Get last error |
| | | */ |
| | | public function getLastError(): string |
| | | { |
| | | return $this->wpdb->last_error; |
| | | } |
| | | |
| | | /** |
| | | * Get number of affected rows from last query |
| | | */ |
| | | public function getAffectedRows(): int |
| | | { |
| | | return $this->wpdb->rows_affected; |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // PRIVATE HELPERS |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Build WHERE clause from associative array |
| | | */ |
| | | private function buildWhereClause(array $where): string |
| | | { |
| | | $conditions = []; |
| | | foreach ($where as $column => $value) { |
| | | $column_safe = esc_sql($column); |
| | | if ($value === null) { |
| | | $conditions[] = "`{$column_safe}` IS NULL"; |
| | | } else { |
| | | $conditions[] = "`{$column_safe}` = " . $this->getPlaceholder($value); |
| | | } |
| | | } |
| | | return implode(' AND ', $conditions); |
| | | } |
| | | |
| | | /** |
| | | * Get appropriate placeholder for value type |
| | | */ |
| | | private function getPlaceholder(mixed $value): string |
| | | { |
| | | if (is_int($value)) { |
| | | return '%d'; |
| | | } elseif (is_float($value)) { |
| | | return '%f'; |
| | | } else { |
| | | return '%s'; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Check if table has a specific column |
| | | */ |
| | | private function hasColumn(string $column): bool |
| | | { |
| | | static $cache = []; |
| | | |
| | | if (!isset($cache[$this->tableName])) { |
| | | $columns = $this->wpdb->get_col("DESCRIBE {$this->fullTableName}"); |
| | | $cache[$this->tableName] = array_flip($columns); |
| | | } |
| | | |
| | | return isset($cache[$this->tableName][$column]); |
| | | } |
| | | |
| | | /** |
| | | * Log database errors |
| | | */ |
| | | private function logError(string $operation, array $context = []): void |
| | | { |
| | | if (function_exists('JVB')) { |
| | | JVB()->error()->log( |
| | | $this->tableName, |
| | | "CustomTable {$operation} failed: " . $this->wpdb->last_error, |
| | | $context, |
| | | 'error' |
| | | ); |
| | | } else { |
| | | error_log("[CustomTable:{$this->tableName}] {$operation} failed: " . $this->wpdb->last_error); |
| | | } |
| | | } |
| | | } |
| | |
| | | <?php |
| | | namespace JVBase\managers; |
| | | |
| | | use JVBase\forms\TaxonomySelector;use JVBase\managers\CRUD; |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\forms\TaxonomySelector; |
| | | use JVBase\managers\CRUD; |
| | | use JVBase\meta\Form; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\utility\Features; |
| | | use JVBase\ui\Navigation; |
| | | use WP_User; |
| | |
| | | |
| | | echo '<h2>What would you like to do today?</h2>'; |
| | | |
| | | echo '<ul>'; |
| | | echo '<ul class="dashboard">'; |
| | | foreach ($pages as $slug => $page) { |
| | | if ($page === 'dash') { |
| | | continue; |
| | |
| | | $jvb_everything = array_merge(JVB_CONTENT, JVB_TAXONOMY); |
| | | |
| | | foreach ($jvb_everything as $type => $settings) { |
| | | $meta = new MetaManager(null, 'form'); |
| | | $fields = jvbGetFields($type); |
| | | ?> |
| | | <template class="<?= $type ?>Table"> |
| | |
| | | <?php |
| | | $config['type'] = 'text'; |
| | | $config['description'] = ''; |
| | | $meta->render('form', $n, $config); |
| | | Form::render($n, null, $config); |
| | | ?> |
| | | </td> |
| | | <?php |
| | |
| | | echo jvbNewModal( |
| | | 'edit-modal '.$type, |
| | | 'Edit '.ucfirst($type), |
| | | $meta->renderForm('admin', [], $fields) |
| | | jvbRenderForm('admin', $fields) |
| | | ); |
| | | } |
| | | |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | add_action('init', [$this, 'registerDirectories']); |
| | | jvb_register_do_once('directories_registered', [$this, 'activate']); |
| | | add_action('render_block', [$this, 'renderBlock'], 99999, 3); |
| | | } |
| | | |
| | |
| | | 'public' => true, |
| | | 'menu_icon' => jvbCSSIcon('list-dashes'), |
| | | 'publicly_queryable' => true, |
| | | 'show_in_menu' => false, |
| | | 'show_in_menu' => true, |
| | | 'show_in_admin_bar' => false, |
| | | 'has_archive' => true, |
| | | 'hierarchical' => true, |
| | |
| | | } |
| | | } |
| | | |
| | | protected function buildDirectoryList():array |
| | | { |
| | | $saved = get_option(BASE.'directory_list', []); |
| | | if (empty($saved)) { |
| | | $all = new WP_Query([ |
| | | 'post_type' => BASE.'directory', |
| | | 'post_status' => 'publish', |
| | | 'posts_per_page' => -1, |
| | | ]); |
| | | foreach($all->posts as $post) { |
| | | $saved[$post->post_name] = [ |
| | | 'slug' => $post->post_name, |
| | | 'title' => $post->post_title, |
| | | 'ID' => $post->ID, |
| | | 'url' => get_the_permalink($post->ID), |
| | | 'page' => $post->post_title, |
| | | 'description' => $this->getConfigFromType($post->post_name)['description']??'', |
| | | 'type' => get_post_meta($post->ID, self::$type,true), |
| | | 'extra' => $this->getConfigFromType($post->post_name)['directory_extra']??[], |
| | | ]; |
| | | } |
| | | update_option(BASE.'directory_list', $saved); |
| | | wp_reset_postdata(); |
| | | } |
| | | return $saved; |
| | | } |
| | | |
| | | public function getDirectoryPageIDs():array |
| | | { |
| | | if (empty($this->directoryPageIDs)) { |
| | |
| | | public function getDirectoryList():array |
| | | { |
| | | if (empty($this->directoryList)) { |
| | | $this->directoryList = get_option(BASE.'directory_list', []); |
| | | $this->directoryList = $this->buildDirectoryList(); |
| | | } |
| | | return $this->directoryList; |
| | | } |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\managers; |
| | | |
| | | use Exception; |
| | | use JVBase\managers\queue\executors\InvitationExecutor; |
| | | use JVBase\managers\queue\TypeConfig; |
| | | use JVBase\utility\Features; |
| | | use WP_Error; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | class InvitationsManager |
| | | { |
| | | protected array $inviteConfig; |
| | | protected CustomTable $table; |
| | | protected int $expiryDays = 14; |
| | | public function __construct() |
| | | { |
| | | $this->setInviteConfig(); |
| | | $this->table = CustomTable::for('invitations'); |
| | | add_action('init', [$this, 'registerInvitationExecutors'], 5); |
| | | |
| | | add_action('user_register', [$this, 'checkInvitation']); |
| | | add_filter('jvbLoginLabels', [$this, 'modifyLoginLabels'], 10, 2); |
| | | add_action('jvb_daily_maintenance', [$this, 'cleanupExpiredInvitations']); |
| | | add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3); |
| | | } |
| | | |
| | | protected function setInviteConfig():void |
| | | { |
| | | $this->inviteConfig = get_option(BASE.'invitation_config', [ |
| | | 'roles' => [], |
| | | 'terms' => [] |
| | | ]); |
| | | } |
| | | |
| | | public function invitableTerms():array |
| | | { |
| | | return $this->inviteConfig['terms']; |
| | | } |
| | | |
| | | public function invitableRoles():array |
| | | { |
| | | return $this->inviteConfig['roles']; |
| | | } |
| | | |
| | | public function getInviteConfig():array |
| | | { |
| | | return $this->inviteConfig; |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Register invitation operation types with queue TypeRegistry |
| | | */ |
| | | public function registerInvitationExecutors(): void |
| | | { |
| | | $registry = JVB()->queue()->registry(); |
| | | $executor = new InvitationExecutor(); |
| | | |
| | | // Create invitations - chunked at 20 |
| | | $registry->register('invitation_create', new TypeConfig( |
| | | executor: $executor, |
| | | chunkKey: 'invitations', |
| | | chunkSize: 20 |
| | | )); |
| | | |
| | | // Resend invitations - chunked at 10 |
| | | $registry->register('invitation_resend', new TypeConfig( |
| | | executor: $executor, |
| | | chunkKey: 'invitations', |
| | | chunkSize: 10 |
| | | )); |
| | | |
| | | // Revoke invitations |
| | | $registry->register('invitation_revoke', new TypeConfig( |
| | | executor: $executor |
| | | )); |
| | | } |
| | | |
| | | /** |
| | | * Build invite types from JVB constants |
| | | * Combines JVB_MEMBERSHIP['can_invite'] and invitable taxonomies |
| | | */ |
| | | protected function buildInviteTypes(): array |
| | | { |
| | | $types = []; |
| | | |
| | | // Global invitations from JVB_MEMBERSHIP |
| | | if (!empty(JVB_MEMBERSHIP['can_invite'])) { |
| | | foreach (JVB_MEMBERSHIP['can_invite'] as $role => $canInvite) { |
| | | $types[$role] = [ |
| | | 'can_invite' => $canInvite, |
| | | 'to_terms' => [] |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | // Term invitations from invitable content taxonomies |
| | | foreach (JVB_TAXONOMY as $taxonomy => $config) { |
| | | if (Features::forTaxonomy($taxonomy)->has('invitable') && |
| | | Features::forTaxonomy($taxonomy)->has('is_content') && |
| | | Features::forTaxonomy($taxonomy)->has('is_ownable')) { |
| | | |
| | | $forContent = $config['for_content'] ?? []; |
| | | foreach ($forContent as $content) { |
| | | // Find which user roles can create this content |
| | | foreach (JVB_USER as $role => $userConfig) { |
| | | $creatable = Features::forUser($role)->getCreatableContent(); |
| | | if (in_array($content, $creatable)) { |
| | | if (!isset($types[$role])) { |
| | | $types[$role] = [ |
| | | 'can_invite' => [], |
| | | 'to_terms' => [] |
| | | ]; |
| | | } |
| | | if (!in_array($taxonomy, $types[$role]['to_terms'])) { |
| | | $types[$role]['to_terms'][] = $taxonomy; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | return $types; |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * UTILITY |
| | | ******************************************************************/ |
| | | public function canInviteToTerm(int $userID, string $taxonomy, int $termID):bool |
| | | { |
| | | $taxonomy = jvbNoBase($taxonomy); |
| | | |
| | | // Check if taxonomy is invitable |
| | | if (!in_array($taxonomy, $this->invitableTerms())) { |
| | | return false; |
| | | } |
| | | |
| | | // User must be owner or manager of the term |
| | | return JVB()->roles()->isManager($userID, $termID); |
| | | } |
| | | |
| | | /** |
| | | * Check if user can send global invitations |
| | | */ |
| | | public function canInviteGlobally(int $userID, string $targetRole): bool |
| | | { |
| | | $userRole = jvbUserRole($userID); |
| | | $allowedRoles = $this->inviteConfig['roles'][$userRole] ?? []; |
| | | |
| | | return in_array($targetRole, $allowedRoles); |
| | | } |
| | | |
| | | public function validateInvitations(int $userID, array $invites): array |
| | | { |
| | | $validated = []; |
| | | |
| | | foreach ($invites as $invite) { |
| | | $sanitized = $this->sanitizeInvitation($invite); |
| | | if (!$sanitized) { |
| | | continue; |
| | | } |
| | | |
| | | // Check permissions |
| | | if ($sanitized['to_term'] && $sanitized['taxonomy']) { |
| | | // Term invitation - check management capability |
| | | if (!$this->canInviteToTerm($userID, $sanitized['taxonomy'], $sanitized['to_term'])) { |
| | | continue; |
| | | } |
| | | } else { |
| | | // Global invitation - check if allowed to invite this role |
| | | if (!$this->canInviteGlobally($userID, $sanitized['invited_role'])) { |
| | | continue; |
| | | } |
| | | } |
| | | |
| | | $validated[] = $sanitized; |
| | | } |
| | | |
| | | return $validated; |
| | | } |
| | | |
| | | /** |
| | | * Sanitize single invitation |
| | | */ |
| | | protected function sanitizeInvitation(array $invite): ?array |
| | | { |
| | | $email = sanitize_email($invite['email'] ?? ''); |
| | | $name = sanitize_text_field($invite['name'] ?? ''); |
| | | $role = $invite['role'] ?? ''; |
| | | |
| | | if (!is_email($email) || empty($name) || empty($role)) { |
| | | return null; |
| | | } |
| | | |
| | | // Check if user already exists |
| | | if (email_exists($email)) { |
| | | return null; |
| | | } |
| | | |
| | | $sanitized = [ |
| | | 'email' => $email, |
| | | 'name' => $name, |
| | | 'invited_role' => $role, |
| | | 'to_term' => isset($invite['to_term']) ? (int) $invite['to_term'] : null, |
| | | 'taxonomy' => isset($invite['taxonomy']) ? jvbNoBase($invite['taxonomy']) : null |
| | | ]; |
| | | |
| | | // Validate term if provided |
| | | if ($sanitized['to_term'] && $sanitized['taxonomy']) { |
| | | $term = get_term($sanitized['to_term'], BASE . $sanitized['taxonomy']); |
| | | if (!$term || is_wp_error($term)) { |
| | | $sanitized['to_term'] = null; |
| | | $sanitized['taxonomy'] = null; |
| | | } |
| | | } |
| | | |
| | | return $sanitized; |
| | | } |
| | | |
| | | /************************************************************************** |
| | | * QUEUE |
| | | **************************************************************************/ |
| | | /** |
| | | * Process bulk invitations (called by queue) |
| | | */ |
| | | public function processOperation(WP_Error|array $result, object $operation, array $data): array|WP_Error |
| | | { |
| | | if ($operation->type !== 'invitation_create') { |
| | | return $result; |
| | | } |
| | | |
| | | return $this->processInvitations($data, $operation->user_id); |
| | | } |
| | | |
| | | /** |
| | | * Process invitations with transaction support |
| | | */ |
| | | protected function processInvitations(array $data, int $userID): array |
| | | { |
| | | $invitations = $data['invitations'] ?? []; |
| | | |
| | | $results = [ |
| | | 'success' => [], |
| | | 'failed' => [] |
| | | ]; |
| | | |
| | | $this->table->startTransaction(); |
| | | |
| | | try { |
| | | foreach ($invitations as $invite) { |
| | | $result = $this->createInvitation( |
| | | $invite['name'], |
| | | $invite['email'], |
| | | $userID, |
| | | $invite['invited_role'], |
| | | $invite['to_term'], |
| | | $invite['taxonomy'], |
| | | false // Don't send email yet |
| | | ); |
| | | |
| | | if (is_wp_error($result)) { |
| | | $results['failed'][] = [ |
| | | 'email' => $invite['email'], |
| | | 'name' => $invite['name'], |
| | | 'reason' => $result->get_error_message() |
| | | ]; |
| | | } else { |
| | | $results['success'][] = array_merge($result, [ |
| | | 'email' => $invite['email'], |
| | | 'name' => $invite['name'] |
| | | ]); |
| | | } |
| | | } |
| | | |
| | | if (!empty($results['success'])) { |
| | | $this->table->commit(); |
| | | |
| | | // Send emails |
| | | foreach ($results['success'] as $invitation) { |
| | | $terms = []; |
| | | if ($invitation['to_term'] && $invitation['taxonomy']) { |
| | | $terms[$invitation['taxonomy']] = $invitation['to_term']; |
| | | } |
| | | |
| | | $this->sendInvitationEmail( |
| | | $invitation['name'], |
| | | $invitation['email'], |
| | | $invitation['token'], |
| | | $userID, |
| | | $terms, |
| | | $invitation['invited_role'] |
| | | ); |
| | | } |
| | | } else { |
| | | $this->table->rollback(); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => count($results['success']) > 0, |
| | | 'results' => $results |
| | | ]; |
| | | |
| | | } catch (Exception $e) { |
| | | $this->table->rollback(); |
| | | |
| | | JVB()->error()->log( |
| | | 'invitation_create', |
| | | 'Error processing invitations: ' . $e->getMessage(), |
| | | ['user_id' => $userID], |
| | | ); |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => [ |
| | | 'failed' => $invitations, |
| | | 'error' => $e->getMessage() |
| | | ] |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Create or update an invitation |
| | | */ |
| | | public function createInvitation( |
| | | string $name, |
| | | string $email, |
| | | int $inviterID, |
| | | string $invitedRole, |
| | | ?int $termID = null, |
| | | ?string $taxonomy = null, |
| | | bool $sendEmail = true |
| | | ): WP_Error|array { |
| | | |
| | | $email = sanitize_email($email); |
| | | if (!is_email($email)) { |
| | | return new WP_Error('invalid_email', 'Invalid email address'); |
| | | } |
| | | |
| | | if (email_exists($email)) { |
| | | return new WP_Error('user_exists', 'User already registered'); |
| | | } |
| | | |
| | | // Check for existing invitation |
| | | $existing = $this->table->get([ |
| | | 'email' => $email, |
| | | 'invited_role' => $invitedRole |
| | | ]); |
| | | |
| | | $token = wp_generate_password(32, false); |
| | | $expiresAt = date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days")); |
| | | |
| | | if ($existing) { |
| | | // Update existing |
| | | $inviters = json_decode($existing->inviters, true) ?: []; |
| | | |
| | | $inviterExists = false; |
| | | foreach ($inviters as &$inviter) { |
| | | if ($inviter['user_id'] == $inviterID) { |
| | | $inviterExists = true; |
| | | $inviter['invited_at'] = current_time('mysql'); |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if (!$inviterExists) { |
| | | $inviters[] = [ |
| | | 'user_id' => $inviterID, |
| | | 'invited_at' => current_time('mysql') |
| | | ]; |
| | | } |
| | | |
| | | $updateData = [ |
| | | 'inviters' => json_encode($inviters), |
| | | 'status' => 'pending', |
| | | 'expires_at' => $expiresAt |
| | | ]; |
| | | |
| | | if ($termID && $taxonomy) { |
| | | $updateData['to_' . $taxonomy] = $termID; |
| | | } |
| | | |
| | | if ($existing->status === 'expired') { |
| | | $updateData['invitation_token'] = $token; |
| | | } else { |
| | | $token = $existing->invitation_token; |
| | | } |
| | | |
| | | $this->table->update($updateData, ['id' => $existing->id]); |
| | | $invitationID = $existing->id; |
| | | |
| | | } else { |
| | | // Create new |
| | | $insertData = [ |
| | | 'name' => sanitize_text_field($name), |
| | | 'email' => $email, |
| | | 'invitation_token' => $token, |
| | | 'invited_role' => $invitedRole, |
| | | 'status' => 'pending', |
| | | 'inviters' => json_encode([[ |
| | | 'user_id' => $inviterID, |
| | | 'invited_at' => current_time('mysql') |
| | | ]]), |
| | | 'expires_at' => $expiresAt |
| | | ]; |
| | | |
| | | if ($termID && $taxonomy) { |
| | | $insertData['to_' . $taxonomy] = $termID; |
| | | } |
| | | |
| | | $invitationID = $this->table->insert($insertData); |
| | | } |
| | | |
| | | if ($sendEmail) { |
| | | $terms = []; |
| | | if ($termID && $taxonomy) { |
| | | $terms[$taxonomy] = $termID; |
| | | } |
| | | $this->sendInvitationEmail($name, $email, $token, $inviterID, $terms, $invitedRole); |
| | | } |
| | | |
| | | return [ |
| | | 'id' => $invitationID, |
| | | 'token' => $token, |
| | | 'expires_at' => $expiresAt, |
| | | 'to_term' => $termID, |
| | | 'taxonomy' => $taxonomy, |
| | | 'invited_role' => $invitedRole |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Send invitation email |
| | | */ |
| | | public function sendInvitationEmail( |
| | | string $name, |
| | | string $email, |
| | | string $token, |
| | | int $inviterID, |
| | | array $terms, |
| | | string $role |
| | | ): void { |
| | | $inviterName = jvbGetUsername($inviterID); |
| | | $siteName = get_bloginfo('name'); |
| | | |
| | | $subject = apply_filters('jvbInvitationSubject', |
| | | sprintf("%s invited you to join %s!", $inviterName, $siteName), |
| | | $inviterName |
| | | ); |
| | | |
| | | $signupUrl = add_query_arg([ |
| | | 'invite' => $token, |
| | | 'email' => urlencode($email), |
| | | 'name' => urlencode($name), |
| | | 'role' => $role |
| | | ], wp_registration_url()); |
| | | |
| | | // Build term-specific content |
| | | $termContent = []; |
| | | foreach ($terms as $taxonomy => $termID) { |
| | | if (!$termID) continue; |
| | | |
| | | $term = get_term($termID, BASE . $taxonomy); |
| | | if ($term && !is_wp_error($term)) { |
| | | $config = JVB_TAXONOMY[$taxonomy] ?? []; |
| | | $singular = $config['singular'] ?? $taxonomy; |
| | | |
| | | $termContent[] = sprintf( |
| | | "<p>%s has also invited you to join %s. You'll be automatically added to this %s when you register.</p>", |
| | | $inviterName, |
| | | html_entity_decode($term->name), |
| | | $singular |
| | | ); |
| | | } |
| | | } |
| | | $termText = implode('', $termContent); |
| | | |
| | | $button = JVB()->email()->button($signupUrl, 'Join the Scene!'); |
| | | $link = JVB()->email()->link($signupUrl); |
| | | $signature = JVB()->email()->signature(); |
| | | |
| | | $message = sprintf( |
| | | '<p>Hi %s!</p> |
| | | <p>%s has invited you to join them on %s.</p> |
| | | %s |
| | | <h2>Interested?</h2> |
| | | <p>Join in by clicking the button below:</p> |
| | | %s |
| | | <p>Or by copying and pasting the link below into your browser:</p> |
| | | %s |
| | | <div class="divider"></div> |
| | | <p>This invitation expires in %d days.</p> |
| | | <p>Ink on,</p> |
| | | %s', |
| | | $name, |
| | | $inviterName, |
| | | $siteName, |
| | | $termText, |
| | | $button, |
| | | $link, |
| | | $this->expiryDays, |
| | | $signature |
| | | ); |
| | | |
| | | $message = apply_filters('jvbInvitationMessage', |
| | | $message, |
| | | $name, |
| | | $inviterName, |
| | | $role, |
| | | $terms, |
| | | $this->expiryDays, |
| | | $button, |
| | | $link, |
| | | $signature |
| | | ); |
| | | |
| | | JVB()->email()->sendEmail($email, $subject, $message); |
| | | } |
| | | |
| | | /** |
| | | * Check invitation on user registration |
| | | * @throws Exception |
| | | */ |
| | | public function checkInvitation(int $userID): void |
| | | { |
| | | $token = sanitize_text_field($_GET['invite'] ?? ''); |
| | | $email = sanitize_email($_GET['email'] ?? ''); |
| | | |
| | | if (!$token || !$email) { |
| | | return; |
| | | } |
| | | |
| | | $user = get_userdata($userID); |
| | | if (!$user || $user->user_email !== $email) { |
| | | return; |
| | | } |
| | | |
| | | $this->acceptInvitation($token, $email, $userID); |
| | | } |
| | | |
| | | /** |
| | | * Accept invitation and grant appropriate capabilities |
| | | * @throws Exception |
| | | */ |
| | | public function acceptInvitation(string $token, string $email, int $userID): bool |
| | | { |
| | | // Verify invitation using fluent CustomTable |
| | | $invitation = $this->table |
| | | ->where([ |
| | | 'invitation_token' => $token, |
| | | 'email' => $email, |
| | | 'status' => 'pending' |
| | | ]) |
| | | ->first(); |
| | | |
| | | if (!$invitation) { |
| | | return false; |
| | | } |
| | | |
| | | // Check expiry |
| | | if (strtotime($invitation->expires_at) < time()) { |
| | | return false; |
| | | } |
| | | |
| | | // Update in transaction |
| | | $success = $this->table->transaction(function($table) use ($invitation, $userID) { |
| | | // Update invitation status |
| | | $table->update( |
| | | [ |
| | | 'status' => 'accepted', |
| | | 'new_user_id' => $userID, |
| | | 'accepted_at' => current_time('mysql') |
| | | ], |
| | | ['id' => $invitation->id] |
| | | ); |
| | | |
| | | // Grant capabilities |
| | | $user = get_userdata($userID); |
| | | $user->add_cap('skip_moderation', true); |
| | | |
| | | return true; |
| | | }); |
| | | |
| | | if (!$success) { |
| | | return false; |
| | | } |
| | | |
| | | // Handle term membership |
| | | $this->processTermMembership($userID, $invitation); |
| | | |
| | | // Notify inviters |
| | | $this->notifyInvitersOfAcceptance($invitation, $userID); |
| | | |
| | | // Invalidate cache |
| | | Cache::for('invitations')->forget('user_' . $userID); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Process term membership for accepted invitation |
| | | */ |
| | | protected function processTermMembership(int $userID, object $invitation): void |
| | | { |
| | | foreach (JVB()->roles()->getInvitableTaxonomies() as $taxonomy) { |
| | | $column = 'to_' . $taxonomy; |
| | | if (isset($invitation->$column) && $invitation->$column) { |
| | | $termID = (int) $invitation->$column; |
| | | |
| | | do_action( |
| | | BASE . 'add_user_to_term', |
| | | $userID, |
| | | $termID, |
| | | $taxonomy, |
| | | 'member' |
| | | ); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Notify inviters that invitation was accepted |
| | | */ |
| | | protected function notifyInvitersOfAcceptance(object $invitation, int $userID): void |
| | | { |
| | | $inviters = json_decode($invitation->inviters, true) ?: []; |
| | | $userData = get_userdata($userID); |
| | | |
| | | foreach ($inviters as $inviter) { |
| | | JVB()->notification()->addNotification( |
| | | $inviter['user_id'], |
| | | 'invitation_accepted', |
| | | [ |
| | | 'invited_email' => $invitation->email, |
| | | 'user_id' => $userID, |
| | | 'display_name' => $userData->display_name |
| | | ] |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Clean up expired invitations (daily cron) |
| | | */ |
| | | public function cleanupExpiredInvitations(): void |
| | | { |
| | | // Use raw query for date comparison |
| | | $this->table->query( |
| | | "UPDATE {$this->table->getFullTableName()} |
| | | SET status = 'expired' |
| | | WHERE status = 'pending' |
| | | AND expires_at < NOW()" |
| | | ); |
| | | |
| | | // Clear cache after cleanup |
| | | Cache::for('invitations')->flush(); |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * Emails |
| | | *****************************************************************/ |
| | | /** |
| | | * Send revocation email |
| | | */ |
| | | public function sendRevocationEmail(string $email, string $name): void |
| | | { |
| | | $siteName = get_bloginfo('name'); |
| | | |
| | | $subject = apply_filters('jvbInvitationRevokedSubject', |
| | | sprintf('[%s] Your invitation has been revoked', $siteName) |
| | | ); |
| | | |
| | | $content = apply_filters('jvbInvitationRevokedMessage', |
| | | sprintf( |
| | | '<p>Hey %s,</p> |
| | | <p>This is to let you know that your invitation to join %s has been revoked.</p> |
| | | <p>If you believe this was done in error, please contact the person who invited you or the site admin.</p>', |
| | | $name, |
| | | $siteName |
| | | ), |
| | | $name |
| | | ); |
| | | |
| | | JVB()->email()->sendEmail($email, $subject, $content, 'INVITATION REVOKED'); |
| | | } |
| | | |
| | | /** |
| | | * TODO: Check with LoginManager.php |
| | | * Modify login labels for invitation flow |
| | | */ |
| | | public function modifyLoginLabels(array $labels, array $getParams): array |
| | | { |
| | | if (!isset($getParams['invite']) || !isset($getParams['email'])) { |
| | | return $labels; |
| | | } |
| | | |
| | | $email = sanitize_email($getParams['email']); |
| | | $token = sanitize_text_field($getParams['invite']); |
| | | $role = $getParams['role'] ?? ''; |
| | | |
| | | if (empty($role)) { |
| | | return $labels; |
| | | } |
| | | |
| | | // Use fluent interface |
| | | $invitation = $this->table |
| | | ->where([ |
| | | 'invitation_token' => $token, |
| | | 'email' => $email, |
| | | 'invited_role' => $role, |
| | | 'status' => 'pending' |
| | | ]) |
| | | ->first(); |
| | | |
| | | if (!$invitation) { |
| | | return $labels; |
| | | } |
| | | |
| | | // Build inviter names |
| | | $inviters = json_decode($invitation->inviters, true) ?: []; |
| | | $names = array_map(fn($inviter) => jvbGetUsername($inviter['user_id']), $inviters); |
| | | |
| | | $message = count($names) > 1 |
| | | ? 'are already here, and have invited you to join in!' |
| | | : ' is already here, and invited you to join in!'; |
| | | |
| | | $labels['title'] = 'Join the Scene, ' . $invitation->name; |
| | | $labels['description'] = [jvbCommaList($names) . ' ' . $message]; |
| | | |
| | | return $labels; |
| | | } |
| | | } |
| | |
| | | <?php |
| | | namespace JVBase\managers; |
| | | |
| | | use JVBase\blocks\CustomBlocks; |
| | | use JVBase\forms\TaxonomySelector; |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\MetaForm; |
| | | use JVBase\managers\AjaxRateLimiter; |
| | | use JVBase\meta\Form; |
| | | |
| | | use JVBase\utility\Features; |
| | | use WP_Error; |
| | | use WP_User; |
| | |
| | | class LoginManager |
| | | { |
| | | protected Features $siteFeatures; |
| | | protected ?MetaForm $metaForm = null; |
| | | protected ?Form $form = null; |
| | | protected Cache $cache; |
| | | |
| | | |
| | |
| | | |
| | | protected function renderForms():void |
| | | { |
| | | $this->metaForm = new MetaForm(); |
| | | $form = $this->action.'form'; |
| | | ?> |
| | | <section class="login-box col btw"> |
| | |
| | | do_action('jvb_add_token_inputs', $this->action); |
| | | |
| | | foreach ($this->fields as $name => $config) { |
| | | $this->metaForm->render($name, '', $config); |
| | | echo Form::render($name, '', $config); |
| | | } |
| | | |
| | | $this->maybeTurnstile(); |
| | |
| | | |
| | | use JVBase\managers\MagicLinkManager; |
| | | use JVBase\integrations\Cloudflare; |
| | | use JVBase\meta\MetaForm; |
| | | use JVBase\meta\Form; |
| | | use JVBase\ui\CRUDSkeleton; |
| | | use JVBase\ui\Tabs; |
| | | use JVBase\utility\Features; |
| | |
| | | JVB()->connect('cloudflare')->renderTurnstile(); |
| | | $turnstile = ob_get_clean(); |
| | | |
| | | $meta = new MetaForm(); |
| | | $reward_text = $this->getRewardText(true); |
| | | |
| | | // Pre-fill code if from referral link |
| | |
| | | <form id="referral-code-form"> |
| | | '.jvbFormStatus(). ' |
| | | <input type="hidden" name="user_select" value="' . esc_attr(get_option(BASE.'referral_role','client')) . '"> |
| | | ' .$meta->return('referral_name', null, [ |
| | | ' .Form::render('referral_name', null, [ |
| | | 'required' => true, |
| | | 'type' => 'text', |
| | | 'label' => 'Your Name', |
| | | 'placeholder'=> 'Mister Meeseeks', |
| | | 'autocomplete'=>'name' |
| | | ]). |
| | | $meta->return('referral_email', null, [ |
| | | Form::render('referral_email', null, [ |
| | | 'required' => true, |
| | | 'type' => 'email', |
| | | 'label' => 'Your Email', |
| | | 'placeholder'=> 'look@me.com', |
| | | 'autocomplete'=> 'email' |
| | | ]). |
| | | $meta->return('referral_code', $prefill_code, [ |
| | | Form::render('referral_code', null, $prefill_code, [ |
| | | 'required' => true, |
| | | 'type' => 'text', |
| | | 'label' => 'Referral Code', |
| | |
| | | </div>'; |
| | | |
| | | $loginForm = '<form id="login-form"> |
| | | '.jvbFormStatus().$meta->return('login_email', null, [ |
| | | '.jvbFormStatus().Form::render('login_email', null, [ |
| | | 'required' => true, |
| | | 'type' => 'email', |
| | | 'label' => 'Your Email', |
| | |
| | | <p>Or, if you prefer, enter your friends name(s) and email(s), and we'll send off some emails.</p> |
| | | <p><small>(No data is stored. Your friends will get an email from our email.)</small></p> |
| | | <?php |
| | | $meta = new MetaForm(); |
| | | $invite = [ |
| | | 'type' => 'tag_list', |
| | | 'label' => 'Invite Your Friends', |
| | |
| | | 'hint' => 'We\'ll add your code and a link automatically.' |
| | | ] |
| | | ]; |
| | | $meta->render('invite', [], $invite); |
| | | echo Form::render('invite', null, $invite); |
| | | ?> |
| | | <details> |
| | | <summary class="icon icon-caret-down">Customize Message</summary> |
| | | <?php |
| | | foreach ($fields as $fieldName => $field) { |
| | | $value = (array_key_exists('value', $field)) ? $field['value'] : []; |
| | | $meta->render($fieldName, $value, $field); |
| | | echo Form::render($fieldName, $value, $field); |
| | | } |
| | | ?> |
| | | </details> |
| | |
| | | $this->registerRole($slug, $config); |
| | | } |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * OWNABLE and MANAGABLE terms (ie: tattoo shops) |
| | | ******************************************************************/ |
| | | /** |
| | | * Grant ownership of a content taxonomy term |
| | | * Owners have full control over the term and its members |
| | | * |
| | | * @param int $userID User ID |
| | | * @param int $termID Term ID |
| | | * @param string $taxonomy Taxonomy slug (without BASE) |
| | | * @return bool Success |
| | | */ |
| | | public function grantOwnership(int $userID, int $termID, string $taxonomy): bool |
| | | { |
| | | if (!get_userdata($userID) || !term_exists($termID)){ |
| | | return false; |
| | | } |
| | | $taxonomy = jvbNoBase($taxonomy); |
| | | |
| | | // Verify this is an ownable content taxonomy |
| | | if (!Features::forTaxonomy($taxonomy)->has('is_content') || |
| | | !Features::forTaxonomy($taxonomy)->has('is_ownable')) { |
| | | return false; |
| | | } |
| | | |
| | | $user = get_userdata($userID); |
| | | if (!$user) { |
| | | return false; |
| | | } |
| | | |
| | | // Grant both ownership and management |
| | | $user->add_cap(BASE . 'can_own_' . $termID); |
| | | $user->add_cap(BASE . 'can_manage_' . $termID); |
| | | |
| | | do_action(BASE . 'granted_ownership', $userID, $termID, $taxonomy); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Revoke ownership of a content taxonomy term |
| | | * |
| | | * @param int $userID User ID |
| | | * @param int $termID Term ID |
| | | * @param string $taxonomy Taxonomy slug (without BASE) |
| | | * @return bool Success |
| | | */ |
| | | public function revokeOwnership(int $userID, int $termID, string $taxonomy): bool |
| | | { |
| | | if (!get_userdata($userID) || !term_exists($termID)){ |
| | | return false; |
| | | } |
| | | $taxonomy = jvbNoBase($taxonomy); |
| | | |
| | | $user = get_userdata($userID); |
| | | if (!$user) { |
| | | return false; |
| | | } |
| | | |
| | | $user->remove_cap(BASE . 'can_own_' . $termID); |
| | | |
| | | do_action(BASE . 'revoked_ownership', $userID, $termID, $taxonomy); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Grant management capabilities for a content taxonomy term |
| | | * Managers can approve members and edit content but don't own the term |
| | | * |
| | | * @param int $userID User ID |
| | | * @param int $termID Term ID |
| | | * @param string $taxonomy Taxonomy slug (without BASE) |
| | | * @return bool Success |
| | | */ |
| | | public function grantManagement(int $userID, int $termID, string $taxonomy): bool |
| | | { |
| | | if (!get_userdata($userID) || !term_exists($termID)){ |
| | | return false; |
| | | } |
| | | $taxonomy = jvbNoBase($taxonomy); |
| | | |
| | | // Verify this is an ownable content taxonomy |
| | | if (!Features::forTaxonomy($taxonomy)->has('is_content') || |
| | | !Features::forTaxonomy($taxonomy)->has('is_ownable')) { |
| | | return false; |
| | | } |
| | | |
| | | $user = get_userdata($userID); |
| | | if (!$user) { |
| | | return false; |
| | | } |
| | | |
| | | $user->add_cap(BASE . 'can_manage_' . $termID); |
| | | |
| | | do_action(BASE . 'granted_management', $userID, $termID, $taxonomy); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Revoke management capabilities for a content taxonomy term |
| | | * |
| | | * @param int $userID User ID |
| | | * @param int $termID Term ID |
| | | * @param string $taxonomy Taxonomy slug (without BASE) |
| | | * @return bool Success |
| | | */ |
| | | public function revokeManagement(int $userID, int $termID, string $taxonomy): bool |
| | | { |
| | | if (!get_userdata($userID) || !term_exists($termID)){ |
| | | return false; |
| | | } |
| | | $taxonomy = jvbNoBase($taxonomy); |
| | | |
| | | $user = get_userdata($userID); |
| | | if (!$user) { |
| | | return false; |
| | | } |
| | | |
| | | $user->remove_cap(BASE . 'can_manage_' . $termID); |
| | | |
| | | do_action(BASE . 'revoked_management', $userID, $termID, $taxonomy); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Check if user owns a term |
| | | * |
| | | * @param int $userID User ID |
| | | * @param int $termID Term ID |
| | | * @return bool |
| | | */ |
| | | public function isOwner(int $userID, int $termID): bool |
| | | { |
| | | return user_can($userID, BASE . 'can_own_' . $termID); |
| | | } |
| | | |
| | | /** |
| | | * Check if user can manage a term (owner or manager) |
| | | * |
| | | * @param int $userID User ID |
| | | * @param int $termID Term ID |
| | | * @return bool |
| | | */ |
| | | public function isManager(int $userID, int $termID): bool |
| | | { |
| | | return user_can($userID, BASE . 'can_manage_' . $termID) || |
| | | user_can($userID, BASE . 'can_own_' . $termID); |
| | | } |
| | | |
| | | /** |
| | | * Get all terms a user owns |
| | | * |
| | | * @param int $userID User ID |
| | | * @param string|null $taxonomy Optional: filter by taxonomy |
| | | * @return array Array of term IDs |
| | | */ |
| | | public function getOwnedTerms(int $userID, ?string $taxonomy = null): array |
| | | { |
| | | $user = get_userdata($userID); |
| | | if (!$user) { |
| | | return []; |
| | | } |
| | | |
| | | $owned = []; |
| | | foreach ($user->allcaps as $cap => $value) { |
| | | if ($value && strpos($cap, BASE . 'can_own_') === 0) { |
| | | $termID = (int) str_replace(BASE . 'can_own_', '', $cap); |
| | | if ($termID) { |
| | | $owned[] = $termID; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Filter by taxonomy if specified |
| | | if ($taxonomy && !empty($owned)) { |
| | | $taxonomy = jvbCheckBase($taxonomy); |
| | | $filtered = []; |
| | | foreach ($owned as $termID) { |
| | | $term = get_term($termID); |
| | | if ($term && !is_wp_error($term) && $term->taxonomy === $taxonomy) { |
| | | $filtered[] = $termID; |
| | | } |
| | | } |
| | | return $filtered; |
| | | } |
| | | |
| | | return $owned; |
| | | } |
| | | |
| | | /** |
| | | * Get all terms a user can manage (owns or manages) |
| | | * |
| | | * @param int $userID User ID |
| | | * @param string|null $taxonomy Optional: filter by taxonomy |
| | | * @return array Array of term IDs |
| | | */ |
| | | public function getManagedTerms(int $userID, ?string $taxonomy = null): array |
| | | { |
| | | $user = get_userdata($userID); |
| | | if (!$user) { |
| | | return []; |
| | | } |
| | | |
| | | $managed = []; |
| | | foreach ($user->allcaps as $cap => $value) { |
| | | if ($value && (strpos($cap, BASE . 'can_manage_') === 0 || |
| | | strpos($cap, BASE . 'can_own_') === 0)) { |
| | | $termID = (int) str_replace([BASE . 'can_manage_', BASE . 'can_own_'], '', $cap); |
| | | if ($termID && !in_array($termID, $managed)) { |
| | | $managed[] = $termID; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Filter by taxonomy if specified |
| | | if ($taxonomy && !empty($managed)) { |
| | | $taxonomy = jvbCheckBase($taxonomy); |
| | | $filtered = []; |
| | | foreach ($managed as $termID) { |
| | | $term = get_term($termID); |
| | | if ($term && !is_wp_error($term) && $term->taxonomy === $taxonomy) { |
| | | $filtered[] = $termID; |
| | | } |
| | | } |
| | | return $filtered; |
| | | } |
| | | |
| | | return $managed; |
| | | } |
| | | |
| | | /** |
| | | * Get all ownable taxonomies |
| | | * |
| | | * @return array Array of taxonomy slugs |
| | | */ |
| | | public function getOwnableTaxonomies(): array |
| | | { |
| | | static $ownable = null; |
| | | |
| | | if ($ownable === null) { |
| | | $ownable = []; |
| | | foreach (JVB_TAXONOMY as $taxonomy => $config) { |
| | | if (Features::forTaxonomy($taxonomy)->has('is_content') && |
| | | Features::forTaxonomy($taxonomy)->has('is_ownable')) { |
| | | $ownable[] = $taxonomy; |
| | | } |
| | | } |
| | | } |
| | | |
| | | return $ownable; |
| | | } |
| | | |
| | | /** |
| | | * Get all invitable taxonomies |
| | | * |
| | | * @return array Array of taxonomy slugs |
| | | */ |
| | | public function getInvitableTaxonomies(): array |
| | | { |
| | | static $invitable = null; |
| | | |
| | | if ($invitable === null) { |
| | | $invitable = []; |
| | | foreach (JVB_TAXONOMY as $taxonomy => $config) { |
| | | if (Features::forTaxonomy($taxonomy)->has('invitable')) { |
| | | $invitable[] = $taxonomy; |
| | | } |
| | | } |
| | | } |
| | | |
| | | return $invitable; |
| | | } |
| | | } |
| | |
| | | namespace JVBase\managers\SEO; |
| | | |
| | | use JVBase\managers\AdminPages; |
| | | use JVBase\meta\MetaForm; |
| | | use JVBase\meta\Form; |
| | | use JVBase\ui\Tabs; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | |
| | | { |
| | | private ConfigManager $config; |
| | | private SchemaBuilder $registry; |
| | | private MetaForm $form; |
| | | |
| | | public function __construct() |
| | | { |
| | | $this->registry = SchemaBuilder::getInstance(); |
| | | $this->form = new MetaForm(); |
| | | |
| | | |
| | | // Add to JVB dashboard |
| | |
| | | } |
| | | $fieldConfig = $this->registry->getFieldDefinition($fieldName); |
| | | |
| | | $this->form->render($fieldName, $config[$fieldName]??'', $fieldConfig); |
| | | echo Form::render($fieldName, $config[$fieldName]??'', $fieldConfig); |
| | | if ($index === 0 && $fieldName === 'type') { |
| | | echo '<div class="seo-'.$type.'">'; |
| | | } |
| | |
| | | $fields = $this->registry->getFieldsForType($type); |
| | | foreach ($fields as $fieldName) { |
| | | $config = $this->registry->getFieldDefinition($fieldName); |
| | | $this->form->render($fieldName, '', $config); |
| | | echo Form::render($fieldName, '', $config); |
| | | } |
| | | ?> |
| | | </div> |
| | |
| | | } |
| | | |
| | | /** |
| | | * Get MetaManager configuration for a schema type |
| | | * Get Meta configuration for a schema type |
| | | * This creates the form fields for the selected @type |
| | | */ |
| | | public function getMetaConfigForType(string $type): array |
| | |
| | | <?php |
| | | namespace JVBase\managers\SEO; |
| | | |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\Meta; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | |
| | | * |
| | | * @param string $fieldName Field name |
| | | * @param mixed $value Raw value |
| | | * @param MetaManager|null $meta Optional MetaManager for accessing related fields |
| | | * @param Meta|null $meta Optional Meta for accessing related fields |
| | | * @return mixed Enhanced value |
| | | */ |
| | | public static function autoResolve(string $fieldName, mixed $value, ?MetaManager $meta = null): mixed |
| | | public static function autoResolve(string $fieldName, mixed $value, ?Meta $meta = null): mixed |
| | | { |
| | | // Skip empty values |
| | | if ($value === null || $value === '') { |
| | |
| | | |
| | | // Rating -> AggregateRating (needs rating_count from meta) |
| | | 'rating' |
| | | => $meta ? self::buildAggregateRating($value, $meta->getValue('rating_count')) : $value, |
| | | => $meta ? self::buildAggregateRating($value, $meta->get('rating_count')) : $value, |
| | | |
| | | // Geo coordinates |
| | | 'geo' |
| | |
| | | * |
| | | * Returns array with 'address' and 'geo' keys |
| | | * |
| | | * @param array $location Location data from MetaManager |
| | | * @param array $location Location data from Meta |
| | | * @return array Schema with address and geo fields |
| | | */ |
| | | public static function buildLocation(array $location): array |
| | |
| | | /** |
| | | * Build opening hours from repeater field |
| | | * |
| | | * @param array $hours Hours data from MetaManager |
| | | * @param array $hours Hours data from Meta |
| | | * @return array Schema with openingHours field |
| | | */ |
| | | public static function buildOpeningHours(array $hours): array |
| | |
| | | /** |
| | | * Build sameAs array from links repeater |
| | | * |
| | | * @param array $links Links data from MetaManager |
| | | * @param array $links Links data from Meta |
| | | * @return array Schema with sameAs field |
| | | */ |
| | | public static function buildSameAs(array $links): array |
| | |
| | | namespace JVBase\managers\SEO; |
| | | |
| | | use JVBase\managers\Cache; |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\Meta; |
| | | use WP_Term; |
| | | use WP_User; |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * Enhanced buildSchemaFromConfig with MetaManager integration |
| | | * Enhanced buildSchemaFromConfig with Meta integration |
| | | */ |
| | | private function buildSchemaFromConfig(array $config, string $schemaType, ?string $id = null): ?array |
| | | { |
| | |
| | | $schema['@id'] = $id; |
| | | } |
| | | |
| | | // Get MetaManager if we have a context |
| | | // Get Meta if we have a context |
| | | $meta = null; |
| | | $context = $this->getCurrentContext(); |
| | | if ($context) { |
| | | $meta = new MetaManager($context['objectId'], $context['objectType']); |
| | | $meta = new Meta($context['objectId'], $context['objectType']); |
| | | } |
| | | |
| | | // Process each field |
| | |
| | | <?php |
| | | namespace JVBase\managers\SEO; |
| | | |
| | | use JVBase\meta\MetaManager; |
| | | use WP_Term; |
| | | use WP_User; |
| | | use WP_Post; |
| | | use JVBase\meta\Meta; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | |
| | | case 'TattooParlor': |
| | | case 'Organization': |
| | | // Add minimal location (just street address) |
| | | $meta = new MetaManager($objectId, $objectType); |
| | | $location = $meta->getValue('location'); |
| | | $meta = new Meta($objectId, $objectType); |
| | | $location = $meta->get('location'); |
| | | |
| | | if ($location && isset($location['address'])) { |
| | | $reference['address'] = [ |
| | |
| | | /** |
| | | * Schema.org Registry - Centralized field and type definitions |
| | | * |
| | | * Field definitions use MetaManager field types and include transformer hints. |
| | | * Field definitions use Meta.php field types and include transformer hints. |
| | | * Types reference field names and support inheritance via 'extends'. |
| | | */ |
| | | class SchemaRegistry |
| | |
| | | } |
| | | |
| | | /** |
| | | * Get MetaManager configuration for a schema type |
| | | * Get Meta configuration for a schema type |
| | | * This creates the form fields for the selected @type |
| | | */ |
| | | public function getMetaConfigForType(string $type): array |
| | |
| | | <?php |
| | | namespace JVBase\managers\SEO; |
| | | |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\Meta; |
| | | use WP_Post; |
| | | use WP_Term; |
| | | use WP_User; |
| | |
| | | private ?int $objectId = null; |
| | | private ?string $objectType = null; |
| | | private ?string $contentType = null; |
| | | private ?MetaManager $meta = null; |
| | | private ?Meta $meta = null; |
| | | private array $context = []; |
| | | private array $fieldDefinitions = []; |
| | | |
| | |
| | | $this->contentType = $contentType; |
| | | |
| | | if ($objectId && $objectType) { |
| | | $this->meta = new MetaManager($objectId, $objectType, $contentType); |
| | | $this->meta = new Meta($objectId, $objectType, $contentType); |
| | | $this->loadFieldDefinitions(); |
| | | } |
| | | |
| | |
| | | return $special; |
| | | } |
| | | |
| | | // Try to get from MetaManager |
| | | // Try to get from Meta.php |
| | | if ($this->meta) { |
| | | $value = $this->meta->getValue($variable); |
| | | $value = $this->meta->get($variable); |
| | | |
| | | // Auto-resolve complex field types via SchemaFieldHelpers |
| | | $value = $this->autoResolveField($variable, $value); |
| | |
| | | * Edmonton.ink Configuration |
| | | * |
| | | * Add this to your edmonton.ink child theme/plugin |
| | | * This replaces all the hardcoded logic in SchemaManager and SEOMetaManager |
| | | * This replaces all the hardcoded logic in SchemaManager and SEO`MetaManager` |
| | | */ |
| | | |
| | | // ================================================== |
| | | // SITE-WIDE SCHEMA CONFIGURATION |
| | | // ================================================== |
| | | |
| | | add_filter('jvb_schema', function ($schema) { |
| | | use JVBase\meta\Meta; |
| | | |
| | | add_filter('jvb_schema', function ($schema) { |
| | | return array_merge($schema, [ |
| | | 'site_type' => 'directory', |
| | | 'organization' => [ |
| | |
| | | |
| | | 'schema' => [ |
| | | 'custom_builder' => function ($post_id) { |
| | | $meta = new \JVBase\meta\MetaManager($post_id, 'post'); |
| | | $meta = Meta::forPost($post_id); |
| | | |
| | | $schema = [ |
| | | '@type' => 'Event', |
| | |
| | | 'url' => get_permalink($post_id), |
| | | ]; |
| | | |
| | | $date = $meta->getValue('event_date'); |
| | | $date = $meta->get('event_date'); |
| | | if ($date) { |
| | | $schema['startDate'] = date('c', strtotime($date)); |
| | | } |
| | | |
| | | $venue = $meta->getValue('venue'); |
| | | $venue_address = $meta->getValue('venue_address'); |
| | | $venue = $meta->get('venue'); |
| | | $venue_address = $meta->get('venue_address'); |
| | | if ($venue) { |
| | | $schema['location'] = [ |
| | | '@type' => 'Place', |
| | |
| | | 'variables' => [ |
| | | 'name' => 'term_name', |
| | | 'city' => ['callback' => function ($term_id, $context) { |
| | | $meta = new \JVBase\meta\MetaManager($term_id, 'term'); |
| | | $city_id = $meta->getValue('city'); |
| | | $meta = Meta::forTerm($term_id); |
| | | $city_id = $meta->get('city'); |
| | | if ($city_id && term_exists((int)$city_id, BASE . 'city')) { |
| | | $city_term = get_term($city_id, BASE . 'city'); |
| | | if ($city_term && !is_wp_error($city_term)) { |
| | |
| | | return 'Edmonton'; |
| | | }], |
| | | 'tagline_text' => ['callback' => function ($term_id) { |
| | | $meta = new \JVBase\meta\MetaManager($term_id, 'term'); |
| | | $tagline = $meta->getValue('tagline'); |
| | | $meta = Meta::forTerm($term_id); |
| | | $tagline = $meta->get('tagline'); |
| | | return $tagline ? " - {$tagline}" : ''; |
| | | }], |
| | | 'established_text' => ['callback' => function ($term_id) { |
| | | $meta = new \JVBase\meta\MetaManager($term_id, 'term'); |
| | | $established = $meta->getValue('established'); |
| | | $meta = Meta::forTerm($term_id); |
| | | $established = $meta->get('established'); |
| | | return $established ? " Established in {$established}" : ''; |
| | | }], |
| | | 'artist_count' => ['callback' => function ($term_id) { |
| | |
| | | 'priceRange' => 'price_range', |
| | | 'image' => 'logo', |
| | | 'url' => ['callback' => function ($term_id) { |
| | | $meta = new \JVBase\meta\MetaManager($term_id, 'term'); |
| | | $website = $meta->getValue('website'); |
| | | $meta = Meta::forTerm($term_id); |
| | | $website = $meta->get('website'); |
| | | return $website ?: get_term_link($term_id); |
| | | }], |
| | | 'sameAs' => ['callback' => function ($term_id) { |
| | | $meta = new \JVBase\meta\MetaManager($term_id, 'term'); |
| | | $meta = Meta::forTerm($term_id); |
| | | $links = []; |
| | | if ($ig = $meta->getValue('instagram')) $links[] = $ig; |
| | | if ($fb = $meta->getValue('facebook')) $links[] = $fb; |
| | | if ($ig = $meta->get('instagram')) $links[] = $ig; |
| | | if ($fb = $meta->get('facebook')) $links[] = $fb; |
| | | return !empty($links) ? $links : null; |
| | | }], |
| | | 'memberOf' => [ |
| | |
| | | 'variables' => [ |
| | | 'name' => 'term_name', |
| | | 'alt_names' => ['callback' => function ($term_id) { |
| | | $meta = new \JVBase\meta\MetaManager($term_id, 'term'); |
| | | $alts = $meta->getValue('alternate_name'); |
| | | $meta = Meta::forTerm($term_id); |
| | | $alts = $meta->get('alternate_name'); |
| | | if (!empty($alts) && is_array($alts)) { |
| | | $names = array_filter(array_column($alts, 'name')); |
| | | if (!empty($names)) { |
| | |
| | | 'description' => 'characteristics', |
| | | 'about' => ['meta' => 'description'], |
| | | 'alternateName' => ['callback' => function ($term_id) { |
| | | $meta = new \JVBase\meta\MetaManager($term_id, 'term'); |
| | | $alts = $meta->getValue('alternate_name'); |
| | | $meta = Meta::forTerm($term_id); |
| | | $alts = $meta->get('alternate_name'); |
| | | if (!empty($alts) && is_array($alts)) { |
| | | return array_filter(array_column($alts, 'name')); |
| | | } |
| | |
| | | 'variables' => [ |
| | | 'name' => 'term_name', |
| | | 'similar' => ['callback' => function ($term_id) { |
| | | $meta = new \JVBase\meta\MetaManager($term_id, 'term'); |
| | | $similar = $meta->getValue('similar'); |
| | | $meta = Meta::forTerm($term_id); |
| | | $similar = $meta->get('similar'); |
| | | if (!empty($similar)) { |
| | | $similar_names = []; |
| | | foreach ((array)$similar as $similar_id) { |
| | |
| | | <?php |
| | | namespace JVBase\managers; |
| | | |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\Meta; |
| | | use WP_Term; |
| | | use DateTime; |
| | | use DateMalformedStringException; |
| | |
| | | exit; // Exit if accessed directly |
| | | } |
| | | /** |
| | | * @deprecated use JVBase\managers\seo\SEO.php |
| | | * SEO Meta Manager for edmonton.ink |
| | | * |
| | | * Integrates with The SEO Framework to generate optimized titles and meta descriptions |
| | |
| | | protected function getPostTitle(int $post_id):string |
| | | { |
| | | $title = get_the_title($post_id); |
| | | $meta = new MetaManager($post_id, 'post'); |
| | | $meta = Meta::forPost($post_id); |
| | | $post_type = get_post_type($post_id); |
| | | |
| | | return match ($post_type) { |
| | |
| | | */ |
| | | protected function getPostDescription(int $post_id):string |
| | | { |
| | | $meta = new MetaManager($post_id, 'post'); |
| | | $meta = Meta::forPost($post_id); |
| | | |
| | | $post_type = get_post_type($post_id); |
| | | switch ($post_type) { |
| | |
| | | */ |
| | | protected function getTermTitle(WP_Term $term):string |
| | | { |
| | | $meta = new MetaManager($term->term_id, 'term'); |
| | | $meta = Meta::forTerm($term->term_id); |
| | | |
| | | switch ($term->taxonomy) { |
| | | case BASE . 'shop': |
| | |
| | | */ |
| | | protected function getTermDescription(WP_Term $term):string |
| | | { |
| | | $meta = new MetaManager($term->term_id, 'term'); |
| | | $meta = Meta::forTerm($term->term_id); |
| | | |
| | | switch ($term->taxonomy) { |
| | | case BASE . 'shop': |
| | |
| | | * Get artist title |
| | | * |
| | | * @param string $title The original title |
| | | * @param MetaManager $meta The meta manager |
| | | * @return string The optimized title |
| | | */ |
| | | protected function getArtistTitle(string $title, MetaManager $meta):string |
| | | protected function getArtistTitle(string $title):string |
| | | { |
| | | $city_terms = get_the_terms(get_the_ID(), BASE . 'city'); |
| | | $city = ($city_terms && !is_wp_error($city_terms)) ? $city_terms[0]->name : 'Edmonton'; |
| | |
| | | * Get artist description |
| | | * |
| | | * @param int $post_id The post ID |
| | | * @param MetaManager $meta The meta manager |
| | | * @param Meta $meta The meta manager |
| | | * @return string The optimized description |
| | | */ |
| | | protected function getArtistDescription(int $post_id, MetaManager $meta):string |
| | | protected function getArtistDescription(int $post_id, Meta $meta):string |
| | | { |
| | | $bio = $meta->getValue('short_bio'); |
| | | $bio = $meta->get('short_bio'); |
| | | if ($bio !== '') { |
| | | return $bio; |
| | | } |
| | | |
| | | $first_name = $meta->getValue('first_name'); |
| | | $first_name = $meta->get('first_name'); |
| | | |
| | | $city_terms = get_the_terms($post_id, BASE . 'city'); |
| | | $city = ($city_terms && !is_wp_error($city_terms)) ? $city_terms[0]->name : 'Edmonton'; |
| | |
| | | |
| | | // Get top styles if available |
| | | $styles = []; |
| | | $top_styles = $meta->getValue('top_style'); |
| | | $top_styles = $meta->get('top_style'); |
| | | if (!empty($top_styles)) { |
| | | foreach ((array)$top_styles as $style_id) { |
| | | $style = get_term($style_id, BASE . 'style'); |
| | |
| | | |
| | | // Get top themes if available |
| | | $themes = []; |
| | | $top_themes = $meta->getValue('top_theme'); |
| | | $top_themes = $meta->get('top_theme'); |
| | | if (!empty($top_themes)) { |
| | | foreach ((array)$top_themes as $theme_id) { |
| | | $theme = get_term($theme_id, BASE . 'theme'); |
| | |
| | | * Get partner description |
| | | * |
| | | * @param int $post_id The post ID |
| | | * @param MetaManager $meta The meta manager |
| | | * @param Meta $meta The meta manager |
| | | * @return string The optimized description |
| | | */ |
| | | protected function getPartnerDescription(int $post_id, MetaManager$meta):string |
| | | protected function getPartnerDescription(int $post_id, Meta$meta):string |
| | | { |
| | | $short_bio = $meta->getValue('short_bio'); |
| | | $short_bio = $meta->get('short_bio'); |
| | | if ($short_bio !== '') { |
| | | return $short_bio; |
| | | } |
| | | $established = $meta->getValue('established'); |
| | | $established = $meta->get('established'); |
| | | |
| | | $description = get_the_title($post_id); |
| | | |
| | |
| | | */ |
| | | protected function getEventTitle(int $post_id, string $title):string |
| | | { |
| | | $meta = new MetaManager($post_id, 'post'); |
| | | $meta = Meta::forPost($post_id); |
| | | |
| | | // Get event type if available |
| | | $event_type = $meta->getValue('event_type') ?: ''; |
| | | $event_type = $meta->get('event_type') ?: ''; |
| | | if ($event_type && term_exists((int)$event_type, BASE . 'type')) { |
| | | $event_type = get_term($event_type, BASE . 'type')->name; |
| | | } |
| | | |
| | | // Get date information |
| | | $date_start = $meta->getValue('date_start'); |
| | | $date_start = $meta->get('date_start'); |
| | | $month = ''; |
| | | if ($date_start) { |
| | | $date = new DateTime($date_start); |
| | |
| | | */ |
| | | protected function getEventDescription(int $post_id):string |
| | | { |
| | | $meta = new MetaManager($post_id, 'post'); |
| | | $meta = Meta::forPost($post_id); |
| | | $title = get_the_title($post_id); |
| | | |
| | | // Get event type if available |
| | | $event_type = $meta->getValue('event_type') ?: ''; |
| | | $event_type = $meta->get('event_type') ?: ''; |
| | | if ($event_type && term_exists((int)$event_type, BASE . 'type')) { |
| | | $event_type = get_term($event_type, BASE . 'type')->name; |
| | | } |
| | | |
| | | // Get date information |
| | | $date_start = $meta->getValue('date_start'); |
| | | $date_start = $meta->get('date_start'); |
| | | $date_format = ''; |
| | | if ($date_start) { |
| | | $date = new DateTime($date_start); |
| | |
| | | } |
| | | |
| | | // Get location information |
| | | $location = $meta->getValue('location'); |
| | | $location = $meta->get('location'); |
| | | $location_name = ''; |
| | | if (!empty($location['shop'])) { |
| | | $shop_term = get_term($location['shop'], BASE . 'shop'); |
| | |
| | | } |
| | | |
| | | // Add event details if available |
| | | $is_free = $meta->getValue('is_free'); |
| | | $is_free = $meta->get('is_free'); |
| | | if ($is_free) { |
| | | $description .= ". Free admission"; |
| | | } else { |
| | | $cost = $meta->getValue('cost'); |
| | | $cost = $meta->get('cost'); |
| | | if ($cost) { |
| | | $description .= ". Admission: {$cost}"; |
| | | } |
| | |
| | | * Get shop title |
| | | * |
| | | * @param WP_Term $term The term object |
| | | * @param MetaManager $meta The meta manager |
| | | * @param Meta $meta The meta manager |
| | | * @return string The optimized title |
| | | */ |
| | | protected function getShopTitle(WP_Term $term, MetaManager $meta):string |
| | | protected function getShopTitle(WP_Term $term, Meta $meta):string |
| | | { |
| | | $city_id = $meta->getValue('city'); |
| | | $city_id = $meta->get('city'); |
| | | $city = 'Edmonton'; |
| | | |
| | | if ($city_id && term_exists((int)$city_id, BASE . 'city')) { |
| | |
| | | * Get shop description |
| | | * |
| | | * @param WP_Term $term The term object |
| | | * @param MetaManager $meta The meta manager |
| | | * @param Meta $meta The meta manager |
| | | * @return string The optimized description |
| | | */ |
| | | protected function getShopDescription(WP_Term $term, MetaManager $meta):string |
| | | protected function getShopDescription(WP_Term $term, Meta $meta):string |
| | | { |
| | | $short_bio = $meta->getValue('short_bio'); |
| | | $short_bio = $meta->get('short_bio'); |
| | | if ($short_bio !== '') { |
| | | return $short_bio; |
| | | } |
| | | |
| | | $established = $meta->getValue('established'); |
| | | $established = $meta->get('established'); |
| | | // Get city |
| | | $city_id = $meta->getValue('city'); |
| | | $city_id = $meta->get('city'); |
| | | $city = 'Edmonton'; |
| | | |
| | | if ($city_id && term_exists((int)$city_id, BASE . 'city')) { |
| | |
| | | * Get style description |
| | | * |
| | | * @param WP_Term $term The term object |
| | | * @param MetaManager $meta The meta manager |
| | | * @param Meta $meta The meta manager |
| | | * @return string The optimized description |
| | | */ |
| | | protected function getStyleDescription(WP_Term $term, MetaManager $meta):string |
| | | protected function getStyleDescription(WP_Term $term, Meta $meta):string |
| | | { |
| | | $tagline = $meta->getValue('tagline'); |
| | | $tagline = $meta->get('tagline'); |
| | | |
| | | if (!$tagline !== '') { |
| | | return $tagline; |
| | | } |
| | | $characteristics = $meta->getValue('characteristics'); |
| | | $alternate_names = $meta->getValue('alternate_name'); |
| | | $characteristics = $meta->get('characteristics'); |
| | | $alternate_names = $meta->get('alternate_name'); |
| | | |
| | | // Get alt names if available |
| | | $alt_name_text = ''; |
| | |
| | | * Get theme description |
| | | * |
| | | * @param WP_Term $term The term object |
| | | * @param MetaManager $meta The meta manager |
| | | * @param Meta $meta The meta manager |
| | | * @return string The optimized description |
| | | */ |
| | | protected function getThemeDescription(WP_Term $term, MetaManager $meta):string |
| | | protected function getThemeDescription(WP_Term $term, Meta $meta):string |
| | | { |
| | | $description_meta = $meta->getValue('description'); |
| | | $description_meta = $meta->get('description'); |
| | | if ($description_meta !== '') { |
| | | return $description_meta; |
| | | } |
| | | |
| | | // Get similar themes if available |
| | | $similar = $meta->getValue('similar'); |
| | | $similar = $meta->get('similar'); |
| | | $similar_text = ''; |
| | | |
| | | if (!empty($similar)) { |
| | |
| | | } |
| | | } |
| | | |
| | | new SEOMetaManager(); |
| | | //new SEOMetaManager(); |
| | |
| | | <?php |
| | | namespace JVBase\managers; |
| | | |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\Meta; |
| | | use WP_Query; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; // Exit if accessed directly |
| | | } |
| | | /** |
| | | * @deprecated use JVBase\managers\seo\SEO.php |
| | | * Schema.org Generator for edmonton.ink |
| | | * |
| | | * This class generates structured schema.org data for better SEO |
| | |
| | | */ |
| | | private function getArtistSchema(int $post_id):array |
| | | { |
| | | $meta = new MetaManager($post_id, 'post'); |
| | | $meta = Meta::forPost($post_id); |
| | | $metaValues = $meta->getAll(); |
| | | |
| | | $permalink = get_permalink($post_id); |
| | | |
| | | // Get artist data |
| | | $name = get_the_title($post_id); |
| | | $first_name = $meta->getValue('first_name'); |
| | | $bio = $meta->getValue('bio'); |
| | | $short_bio = $meta->getValue('short_bio'); |
| | | $first_name = $meta->get('first_name'); |
| | | $bio = $meta->get('bio'); |
| | | $short_bio = $meta->get('short_bio'); |
| | | $description = $short_bio ?: wp_strip_all_tags($bio) ?: get_the_excerpt($post_id); |
| | | |
| | | // Build person schema |
| | |
| | | */ |
| | | private function getShopSchema(int $term_id): array |
| | | { |
| | | $meta = new MetaManager($term_id, 'term'); |
| | | $meta = Meta::forTerm($term_id); |
| | | $metaValues = $meta->getAll(); |
| | | $term = get_term($term_id, BASE.'shop'); |
| | | $permalink = get_term_link($term_id, BASE.'shop'); |
| | |
| | | '@type' => 'LocalBusiness', |
| | | '@id' => $permalink . '#organization', |
| | | 'name' => html_entity_decode($term->name), |
| | | 'description' => $meta->getValue('short_bio') ?: $term->description, |
| | | 'description' => $meta->get('short_bio') ?: $term->description, |
| | | 'url' => $permalink, |
| | | 'priceRange' => '$$', // Default price range |
| | | 'additionalType' => 'https://schema.org/TattooParlor', // Custom business type |
| | |
| | | */ |
| | | private function getStyleSchema(int $term_id):array |
| | | { |
| | | $meta = new MetaManager($term_id, 'term'); |
| | | $meta = Meta::forTerm($term_id); |
| | | $term = get_term($term_id, BASE.'style'); |
| | | $permalink = get_term_link($term_id, BASE.'style'); |
| | | |
| | |
| | | '@type' => 'CreativeWork', |
| | | '@id' => $permalink . '#style', |
| | | 'name' => html_entity_decode($term->name), |
| | | 'description' => $meta->getValue('characteristics') ?: $term->description, |
| | | 'description' => $meta->get('characteristics') ?: $term->description, |
| | | 'url' => $permalink, |
| | | 'mainEntityOfPage' => [ |
| | | '@type' => 'WebPage', |
| | |
| | | */ |
| | | private function getThemeSchema(int $term_id):array |
| | | { |
| | | $meta = new MetaManager($term_id, 'term'); |
| | | $meta = Meta::forTerm($term_id); |
| | | $term = get_term($term_id, BASE.'theme'); |
| | | $permalink = get_term_link($term_id, BASE.'theme'); |
| | | |
| | |
| | | '@type' => 'CreativeWork', |
| | | '@id' => $permalink . '#theme', |
| | | 'name' => html_entity_decode($term->name), |
| | | 'description' => $meta->getValue('description') ?: $term->description, |
| | | 'description' => $meta->get('description') ?: $term->description, |
| | | 'url' => $permalink, |
| | | 'mainEntityOfPage' => [ |
| | | '@type' => 'WebPage', |
| | |
| | | */ |
| | | private function getPartnerSchema(int $post_id):array |
| | | { |
| | | $meta = new MetaManager($post_id, 'post'); |
| | | $meta = Meta::forPost($post_id); |
| | | $metaValues = $meta->getAll(); |
| | | $permalink = get_permalink($post_id); |
| | | |
| | |
| | | add_action('init', 'jvbRegisterScripts', 5); |
| | | |
| | | function jvbRegisterScripts() { |
| | | $version = '1.1.29'; |
| | | $version = '1.1.3'; |
| | | $strategy = [ |
| | | 'strategy' => 'defer', |
| | | 'in_footer' => true |
| | |
| | | use JVBase\utility\Features; |
| | | |
| | | require(JVB_DIR . '/inc/managers/ScriptLoader.php'); |
| | | require(JVB_DIR . '/inc/managers/CustomTable.php'); |
| | | //require(JVB_DIR . '/inc/managers/CacheManager.php'); |
| | | require(JVB_DIR . '/inc/managers/Cache.php'); |
| | | class_alias('JVBase\managers\Cache', 'JVBase\managers\CacheManager'); |
| | |
| | | require(JVB_DIR . '/inc/managers/NewsRelationships.php'); |
| | | } |
| | | |
| | | if (Features::forMembership()->has('invitable')) { |
| | | require(JVB_DIR . '/inc/managers/Invitations.php'); |
| | | } |
| | | |
| | | // |
| | | //require(JVB_DIR . '/inc/managers/SchemaManager.php'); |
| | | //require(JVB_DIR . '/inc/managers/SEOMetaManager.php'); |
| | |
| | | |
| | | $ops = $this->storage->fetchRunnable(3); |
| | | |
| | | $lastOpId = null; |
| | | foreach ($ops as $op) { |
| | | if (!$this->dependenciesSatisfied($op)) { |
| | | continue; |
| | | } |
| | | if (!$this->storage->markProcessing($op->id)) { |
| | | continue; |
| | | } |
| | | $lastOpId = $op->id; |
| | | $this->processOne($op); |
| | | usleep(10000); |
| | | } |
| | |
| | | |
| | | return true; |
| | | } |
| | | |
| | | private function dependenciesSatisfied(Operation $op): bool |
| | | { |
| | | if (empty($op->dependencies)) { |
| | | return true; |
| | | } |
| | | |
| | | foreach ($op->dependencies as $depId) { |
| | | $dep = $this->storage->find($depId); |
| | | |
| | | // Missing dependency = block (or decide to ignore; your call) |
| | | if (!$dep) { |
| | | return false; |
| | | } |
| | | |
| | | if ($dep->state !== 'completed') { |
| | | return false; |
| | | } |
| | | |
| | | if (!in_array($dep->outcome, ['success', 'partial'], true)) { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | } |
| | |
| | | public function maintenance(): void |
| | | { |
| | | $this->locker->withLock(function () { |
| | | $this->cleanupStuck(); |
| | | $this->storage->resetStuckOperations(30); |
| | | }); |
| | | |
| | | } |
| | | |
| | | private function cleanupStuck(): void |
| | | { |
| | | $this->storage->resetStuckOperations(30); |
| | | } |
| | | |
| | | // === Public Getters === |
| | | |
| | | public function get(string $id): ?Operation |
| | |
| | | public string $outcome, // success | partial | failed |
| | | public ?array $result = null |
| | | ) {} |
| | | |
| | | public static function fail(string $message): Result |
| | | { |
| | | return new self('failed', ['message' => $message]); |
| | | } |
| | | |
| | | public static function success(array $data): Result |
| | | { |
| | | return new self('success', $data); |
| | | } |
| | | } |
| | |
| | | { |
| | | $now = current_time('mysql'); |
| | | |
| | | $rows = $this->wpdb->get_results($this->wpdb->prepare(" |
| | | SELECT oq.* FROM {$this->table} oq |
| | | WHERE oq.state IN ('pending', 'scheduled') |
| | | AND oq.scheduled_at <= %s |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM JSON_TABLE( |
| | | COALESCE(NULLIF(oq.dependencies, 'null'), '[]'), |
| | | '\$[*]' COLUMNS (dep_id VARCHAR(64) PATH '\$') |
| | | ) AS deps |
| | | JOIN {$this->table} dep ON dep.id = deps.dep_id |
| | | WHERE dep.state != 'completed' |
| | | OR dep.outcome NOT IN ('success', 'partial') |
| | | ) |
| | | ORDER BY FIELD(oq.priority, 'high', 'normal', 'low'), oq.scheduled_at |
| | | LIMIT %d |
| | | FOR UPDATE SKIP LOCKED |
| | | ", $now, $limit)); |
| | | $rows = $this->wpdb->get_results( |
| | | $this->wpdb->prepare(" |
| | | SELECT * |
| | | FROM {$this->table} |
| | | WHERE state IN ('pending', 'scheduled') |
| | | AND scheduled_at <= %s |
| | | ORDER BY |
| | | FIELD(priority, 'high', 'normal', 'low'), |
| | | scheduled_at |
| | | LIMIT %d |
| | | FOR UPDATE SKIP LOCKED |
| | | ", $now, $limit) |
| | | ); |
| | | |
| | | return array_map([$this, 'rowToOperation'], $rows ?: []); |
| | | } |
| | |
| | | require_once JVB_DIR . '/inc/managers/queue/Processor.php'; |
| | | require_once JVB_DIR . '/inc/managers/queue/executors/UploadExecutor.php'; |
| | | require_once JVB_DIR . '/inc/managers/queue/executors/ContentExecutor.php'; |
| | | require_once JVB_DIR . '/inc/managers/queue/executors/InvitationExecutor.php'; |
| | | |
| | | // Facade |
| | | require_once JVB_DIR . '/inc/managers/queue/Queue.php'; |
| | |
| | | namespace JVBase\managers\queue\executors; |
| | | |
| | | use JVBase\managers\queue\{Executor, Operation, Progress, Result, Storage}; |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\utility\Features; |
| | | use Exception; |
| | | |
| | |
| | | return true; |
| | | } |
| | | |
| | | $meta = new MetaManager($postId, 'post'); |
| | | return $meta->setAll($allowedFields); |
| | | $meta = Meta::forPost($postId); |
| | | $meta->setAll($allowedFields); |
| | | return true; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | |
| | | |
| | | $lastKey = array_key_last($posts); |
| | | foreach ($posts as $index => $post) { |
| | | $meta = new MetaManager($post->ID, 'post'); |
| | | $meta = Meta::forPost($post->ID); |
| | | if ($index === 0) { |
| | | $meta->updateValue('timeline', '', false); |
| | | $meta->set('timeline', '', false); |
| | | $previousPost = $post; |
| | | continue; // Parent has no timeline |
| | | } |
| | |
| | | if ($timeline) { |
| | | $termId = $this->getOrCreateTerm($timeline, 'timeline'); |
| | | if ($termId) { |
| | | $success = $meta->updateValue('timeline', $termId, false); |
| | | $success = $meta->set('timeline', $termId, false); |
| | | } |
| | | } |
| | | } |
| | |
| | | protected function checkSharedFields(array $fields): void |
| | | { |
| | | foreach ($fields as $parentID => $shared) { |
| | | $meta = new MetaManager($parentID, 'post'); |
| | | $meta = Meta::forPost($parentID); |
| | | $values = $meta->getAll($shared); |
| | | |
| | | $children = get_children([ |
| | |
| | | } |
| | | |
| | | foreach ($children as $child) { |
| | | $childMeta = new MetaManager($child, 'post'); |
| | | $childMeta = Meta::forPost($child); |
| | | $result = $childMeta->setAll($values, false); |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\managers\queue\executors; |
| | | |
| | | use JVBase\managers\CustomTable; |
| | | use JVBase\managers\queue\Executor; |
| | | use JVBase\managers\queue\Operation; |
| | | use JVBase\managers\queue\Progress; |
| | | use JVBase\managers\queue\Result; |
| | | use JVBase\managers\RoleManager; |
| | | use WP_Error; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | class InvitationExecutor implements Executor |
| | | { |
| | | protected CustomTable $table; |
| | | protected RoleManager $roleManager; |
| | | protected int $expiryDays = 14; |
| | | |
| | | public function __construct() |
| | | { |
| | | $this->table = CustomTable::for('invitations'); |
| | | $this->roleManager = new RoleManager(); |
| | | } |
| | | |
| | | public function execute(Operation $operation, Progress $progress): Result |
| | | { |
| | | return match($operation->type) { |
| | | 'invitation_create' => $this->processCreation($operation, $progress), |
| | | 'invitation_resend' => $this->processResend($operation, $progress), |
| | | 'invitation_revoke' => $this->processRevoke($operation, $progress), |
| | | default => Result::fail("Unknown operation type: {$operation->type}") |
| | | }; |
| | | } |
| | | |
| | | protected function processCreation(Operation $operation, Progress $progress): Result |
| | | { |
| | | $invitations = $operation->requestData['invitations'] ?? []; |
| | | $userID = $operation->userId; |
| | | |
| | | $results = [ |
| | | 'success' => [], |
| | | 'failed' => [] |
| | | ]; |
| | | |
| | | $this->table->startTransaction(); |
| | | |
| | | try { |
| | | foreach ($invitations as $index => $invite) { |
| | | $progress->track($index); |
| | | |
| | | $result = $this->createSingleInvitation( |
| | | $invite['name'], |
| | | $invite['email'], |
| | | $userID, |
| | | $invite['invited_role'], |
| | | $invite['to_term'] ?? null, |
| | | $invite['taxonomy'] ?? null |
| | | ); |
| | | |
| | | if (is_wp_error($result)) { |
| | | $results['failed'][] = [ |
| | | 'email' => $invite['email'], |
| | | 'name' => $invite['name'], |
| | | 'reason' => $result->get_error_message() |
| | | ]; |
| | | } else { |
| | | $results['success'][] = $result; |
| | | } |
| | | } |
| | | |
| | | if (!empty($results['success'])) { |
| | | $this->table->commit(); |
| | | |
| | | // Send emails after successful commit |
| | | foreach ($results['success'] as $invitation) { |
| | | $this->sendInvitationEmail($invitation, $userID); |
| | | } |
| | | } else { |
| | | $this->table->rollback(); |
| | | } |
| | | |
| | | return Result::success($results, [ |
| | | 'sent' => count($results['success']), |
| | | 'failed' => count($results['failed']) |
| | | ]); |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->table->rollback(); |
| | | return Result::fail($e->getMessage()); |
| | | } |
| | | } |
| | | |
| | | protected function createSingleInvitation( |
| | | string $name, |
| | | string $email, |
| | | int $inviterID, |
| | | string $invitedRole, |
| | | ?int $termID = null, |
| | | ?string $taxonomy = null |
| | | ): WP_Error|array { |
| | | |
| | | // Check for existing invitation |
| | | $existing = $this->table->get([ |
| | | 'email' => $email, |
| | | 'invited_role' => $invitedRole |
| | | ]); |
| | | |
| | | $token = wp_generate_password(32, false); |
| | | $expiresAt = date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days")); |
| | | |
| | | if ($existing) { |
| | | // Update existing |
| | | $inviters = json_decode($existing->inviters, true) ?: []; |
| | | |
| | | $inviterExists = false; |
| | | foreach ($inviters as &$inviter) { |
| | | if ($inviter['user_id'] == $inviterID) { |
| | | $inviterExists = true; |
| | | $inviter['invited_at'] = current_time('mysql'); |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if (!$inviterExists) { |
| | | $inviters[] = [ |
| | | 'user_id' => $inviterID, |
| | | 'invited_at' => current_time('mysql') |
| | | ]; |
| | | } |
| | | |
| | | $updateData = [ |
| | | 'inviters' => json_encode($inviters), |
| | | 'status' => 'pending', |
| | | 'expires_at' => $expiresAt |
| | | ]; |
| | | |
| | | if ($termID && $taxonomy) { |
| | | $updateData['to_' . $taxonomy] = $termID; |
| | | } |
| | | |
| | | if ($existing->status === 'expired') { |
| | | $updateData['invitation_token'] = $token; |
| | | } else { |
| | | $token = $existing->invitation_token; |
| | | } |
| | | |
| | | $this->table->update($updateData, ['id' => $existing->id]); |
| | | $invitationID = $existing->id; |
| | | |
| | | } else { |
| | | // Create new |
| | | $insertData = [ |
| | | 'name' => sanitize_text_field($name), |
| | | 'email' => $email, |
| | | 'invitation_token' => $token, |
| | | 'invited_role' => $invitedRole, |
| | | 'status' => 'pending', |
| | | 'inviters' => json_encode([[ |
| | | 'user_id' => $inviterID, |
| | | 'invited_at' => current_time('mysql') |
| | | ]]), |
| | | 'expires_at' => $expiresAt |
| | | ]; |
| | | |
| | | if ($termID && $taxonomy) { |
| | | $insertData['to_' . $taxonomy] = $termID; |
| | | } |
| | | |
| | | $invitationID = $this->table->insert($insertData); |
| | | } |
| | | |
| | | return [ |
| | | 'id' => $invitationID, |
| | | 'token' => $token, |
| | | 'expires_at' => $expiresAt, |
| | | 'to_term' => $termID, |
| | | 'taxonomy' => $taxonomy, |
| | | 'invited_role' => $invitedRole, |
| | | 'email' => $email, |
| | | 'name' => $name |
| | | ]; |
| | | } |
| | | |
| | | protected function sendInvitationEmail(array $invitation, int $inviterID): void |
| | | { |
| | | $terms = []; |
| | | if ($invitation['to_term'] && $invitation['taxonomy']) { |
| | | $terms[$invitation['taxonomy']] = $invitation['to_term']; |
| | | } |
| | | |
| | | // This would call your email service |
| | | do_action( |
| | | BASE . 'send_invitation_email', |
| | | $invitation['name'], |
| | | $invitation['email'], |
| | | $invitation['token'], |
| | | $inviterID, |
| | | $terms, |
| | | $invitation['invited_role'] |
| | | ); |
| | | } |
| | | |
| | | protected function processResend(Operation $operation, Progress $progress): Result |
| | | { |
| | | $invitationID = $operation->requestData['invitation_id'] ?? 0; |
| | | $userID = $operation->userId; |
| | | |
| | | if (!$invitationID) { |
| | | return Result::fail('Invitation ID required'); |
| | | } |
| | | |
| | | // Get invitation |
| | | $invitation = $this->table->get(['id' => $invitationID]); |
| | | |
| | | if (!$invitation) { |
| | | return Result::fail('Invitation not found'); |
| | | } |
| | | |
| | | // Verify status |
| | | if (!in_array($invitation->status, ['pending', 'expired'])) { |
| | | return Result::fail('Only pending or expired invitations can be resent'); |
| | | } |
| | | |
| | | // Check if user is an inviter |
| | | $inviters = json_decode($invitation->inviters, true) ?: []; |
| | | $isInviter = false; |
| | | |
| | | foreach ($inviters as &$inviter) { |
| | | if ($inviter['user_id'] == $userID) { |
| | | $isInviter = true; |
| | | $inviter['invited_at'] = current_time('mysql'); |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if (!$isInviter) { |
| | | return Result::fail('You are not authorized to resend this invitation'); |
| | | } |
| | | |
| | | // Generate new token and expiry |
| | | $token = wp_generate_password(32, false); |
| | | $expiresAt = date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days")); |
| | | |
| | | // Update invitation |
| | | $this->table->update( |
| | | [ |
| | | 'invitation_token' => $token, |
| | | 'status' => 'pending', |
| | | 'expires_at' => $expiresAt, |
| | | 'inviters' => json_encode($inviters) |
| | | ], |
| | | ['id' => $invitation->id] |
| | | ); |
| | | |
| | | // Build term data for email |
| | | $terms = []; |
| | | foreach ($this->roleManager->getInvitableTaxonomies() as $taxonomy) { |
| | | $column = 'to_' . $taxonomy; |
| | | if (isset($invitation->$column) && $invitation->$column) { |
| | | $terms[$taxonomy] = (int) $invitation->$column; |
| | | } |
| | | } |
| | | |
| | | // Send email |
| | | do_action( |
| | | BASE . 'send_invitation_email', |
| | | $invitation->name, |
| | | $invitation->email, |
| | | $token, |
| | | $userID, |
| | | $terms, |
| | | $invitation->invited_role |
| | | ); |
| | | |
| | | return Result::success([ |
| | | 'message' => 'Invitation resent successfully', |
| | | 'expires_at' => $expiresAt |
| | | ]); |
| | | } |
| | | |
| | | protected function processRevoke(Operation $operation, Progress $progress): Result |
| | | { |
| | | $invitationID = $operation->requestData['invitation_id'] ?? 0; |
| | | $userID = $operation->userId; |
| | | |
| | | if (!$invitationID) { |
| | | return Result::fail('Invitation ID required'); |
| | | } |
| | | |
| | | // Get invitation |
| | | $invitation = $this->table->get(['id' => $invitationID]); |
| | | |
| | | if (!$invitation) { |
| | | return Result::fail('Invitation not found'); |
| | | } |
| | | |
| | | // Can only revoke pending/expired |
| | | if (!in_array($invitation->status, ['pending', 'expired'])) { |
| | | return Result::fail('Only pending or expired invitations can be revoked'); |
| | | } |
| | | |
| | | // Check if user is an inviter |
| | | $inviters = json_decode($invitation->inviters, true) ?: []; |
| | | $isInviter = false; |
| | | $updatedInviters = []; |
| | | |
| | | foreach ($inviters as $inviter) { |
| | | if ($inviter['user_id'] == $userID) { |
| | | $isInviter = true; |
| | | } else { |
| | | $updatedInviters[] = $inviter; |
| | | } |
| | | } |
| | | |
| | | if (!$isInviter) { |
| | | return Result::fail('You are not authorized to revoke this invitation'); |
| | | } |
| | | |
| | | // If other inviters remain, just update list |
| | | if (!empty($updatedInviters)) { |
| | | $this->table->update( |
| | | ['inviters' => json_encode($updatedInviters)], |
| | | ['id' => $invitation->id] |
| | | ); |
| | | |
| | | return Result::success([ |
| | | 'message' => 'You have been removed from the inviters list', |
| | | 'fully_revoked' => false |
| | | ]); |
| | | } |
| | | |
| | | // No inviters left, revoke completely |
| | | $this->table->update( |
| | | ['status' => 'revoked'], |
| | | ['id' => $invitation->id] |
| | | ); |
| | | |
| | | // Send revocation email |
| | | do_action( |
| | | BASE . 'send_revocation_email', |
| | | $invitation->email, |
| | | $invitation->name |
| | | ); |
| | | |
| | | return Result::success([ |
| | | 'message' => 'Invitation revoked successfully', |
| | | 'fully_revoked' => true |
| | | ]); |
| | | } |
| | | } |
| | |
| | | |
| | | use JVBase\managers\queue\{Executor, Operation, Progress, Result}; |
| | | use JVBase\managers\UploadManager; |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\Meta; |
| | | use Exception; |
| | | use JVBase\utility\Features; |
| | | |
| | |
| | | } |
| | | |
| | | if (!empty($gallery_attachment_ids)) { |
| | | $meta = new MetaManager($newPostID, 'post'); |
| | | $meta = Meta::forPost($newPostID); |
| | | $fields = jvbGetFields($content, 'post'); |
| | | foreach($fields as $name => $config) { |
| | | if ($config['type'] === 'gallery') { |
| | | $meta->updateValue($name, implode(',', $gallery_attachment_ids)); |
| | | $meta->set($name, implode(',', $gallery_attachment_ids)); |
| | | break; |
| | | } |
| | | } |
| | |
| | | return; |
| | | } |
| | | |
| | | $existing = $meta->getValue($data['field_name']); |
| | | $existing = $meta->get($data['field_name']); |
| | | $existingIds = !empty($existing) ? explode(',', $existing) : []; |
| | | $allIds = array_unique(array_merge($existingIds, $attachmentIds)); |
| | | |
| | | $meta->updateValue($data['field_name'], implode(',', $allIds)); |
| | | $meta->set($data['field_name'], implode(',', $allIds)); |
| | | } |
| | | |
| | | private function updateFieldValue(array $data, array $results): void |
| | |
| | | return; |
| | | } |
| | | |
| | | $existing = $meta->getValue($data['field_name']); |
| | | $existing = $meta->get($data['field_name']); |
| | | $existingIds = !empty($existing) ? explode(',', $existing) : []; |
| | | $allIds = array_unique(array_merge($existingIds, $attachmentIds)); |
| | | |
| | | $meta->updateValue($data['field_name'], implode(',', $allIds)); |
| | | $meta->set($data['field_name'], implode(',', $allIds)); |
| | | } |
| | | |
| | | private function getMetaManager(array $data): ?MetaManager |
| | | private function getMetaManager(array $data): ?Meta |
| | | { |
| | | if (!empty($data['post_id'])) { |
| | | return new MetaManager($data['post_id'], 'post'); |
| | | return Meta::forPost($data['post_id']); |
| | | } |
| | | if (!empty($data['term_id'])) { |
| | | return new MetaManager($data['term_id'], 'term'); |
| | | return Meta::forTerm($data['term_id']); |
| | | } |
| | | if (!empty($data['user'])) { |
| | | $link = (int)get_user_meta($data['user'], BASE . 'link', true); |
| | | if ($link) { |
| | | return new MetaManager($link, 'post'); |
| | | return Meta::forPost($link); |
| | | } |
| | | } |
| | | return null; |
| | |
| | | if (str_starts_with($mimeType, 'image/')) { |
| | | set_post_thumbnail($postId, $attachmentId); |
| | | } elseif (str_starts_with($mimeType, 'video/')) { |
| | | $meta = new MetaManager($postId, 'post'); |
| | | $meta->updateValue('video', $attachmentId); |
| | | $meta = Meta::forPost($postId); |
| | | $meta->set('video', $attachmentId); |
| | | } else { |
| | | $meta = new MetaManager($postId, 'post'); |
| | | $existing = $meta->getValue('documents'); |
| | | $meta = Meta::forPost($postId); |
| | | $existing = $meta->get('documents'); |
| | | $existingIds = !empty($existing) ? explode(',', $existing) : []; |
| | | $existingIds[] = $attachmentId; |
| | | $meta->updateValue('documents', implode(',', $existingIds)); |
| | | $meta->set('documents', implode(',', $existingIds)); |
| | | } |
| | | } |
| | | |
| | |
| | | $this->config = $config; |
| | | } |
| | | |
| | | /** |
| | | * Set field value and track dirty state |
| | | */ |
| | | public function set(mixed $value): self |
| | | { |
| | | $this->value = $value; |
| | |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Get current value |
| | | */ |
| | | public function get(): mixed |
| | | { |
| | | return $this->value; |
| | | } |
| | | |
| | | /** |
| | | * Mark field as clean (after save) |
| | | */ |
| | | public function markClean(): self |
| | | { |
| | | $this->originalValue = $this->value; |
| | |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Reset to original value |
| | | */ |
| | | public function reset(): self |
| | | { |
| | | $this->value = $this->originalValue; |
| | |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Add validation error |
| | | */ |
| | | public function addError(string $message): self |
| | | { |
| | | $this->errors[] = $message; |
| | |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Clear all errors |
| | | */ |
| | | public function clearErrors(): self |
| | | { |
| | | $this->errors = []; |
| | |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Get field type from config |
| | | */ |
| | | public function type(): string |
| | | { |
| | | return $this->config['type'] ?? 'text'; |
| | | } |
| | | |
| | | /** |
| | | * Check if this is a WordPress default field |
| | | */ |
| | | public function isWpDefault(): bool |
| | | { |
| | | return $this->config['_wp_default'] ?? false; |
| | | } |
| | | |
| | | /** |
| | | * Check if this is a taxonomy relationship field (not taxonomy_type) |
| | | */ |
| | | public function isTaxonomy(): bool |
| | | { |
| | | return $this->type() === 'taxonomy' && !isset($this->config['taxonomy_type']); |
| | | } |
| | | |
| | | /** |
| | | * Check if field is required |
| | | */ |
| | | public function isRequired(): bool |
| | | { |
| | | return !empty($this->config['required']); |
| | | } |
| | | |
| | | /** |
| | | * Get field label |
| | | */ |
| | | public function label(): string |
| | | { |
| | | return $this->config['label'] ?? $this->name; |
| | | } |
| | | |
| | | /** |
| | | * Get field description |
| | | */ |
| | | public function description(): string |
| | | { |
| | | return $this->config['description'] ?? ''; |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\meta; |
| | | |
| | | use DateTime; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Static utility for rendering form fields |
| | | * |
| | | * Usage: |
| | | * echo Form::render('price', 150, ['type' => 'number', 'label' => 'Price']); |
| | | * echo Form::render('email', '', ['type' => 'email', 'required' => true]); |
| | | */ |
| | | class Form |
| | | { |
| | | /** |
| | | * Render a form field based on type |
| | | */ |
| | | public static function render(string $name, mixed $value, array $config = []): string |
| | | { |
| | | if ($value === null) { |
| | | $value = ''; |
| | | } |
| | | if (!empty($config['hidden'])) { |
| | | return ''; |
| | | } |
| | | |
| | | $type = $config['type'] ?? 'text'; |
| | | $method = 'render' . str_replace('_', '', ucwords($type, '_')); |
| | | |
| | | $output = method_exists(static::class, $method) |
| | | ? static::$method($name, $value, $config) |
| | | : static::renderText($name, $value, $config); |
| | | |
| | | return apply_filters('jvbRenderFormMeta', $output, $name, $config, $value, null); |
| | | } |
| | | |
| | | /** |
| | | * Render with Meta instance (convenience method) |
| | | */ |
| | | public static function renderFrom(Meta $meta, string $name): string |
| | | { |
| | | $value = $meta->get($name); |
| | | $config = $meta->config($name) ?? ['type' => 'text']; |
| | | |
| | | return static::render($name, $value, $config); |
| | | } |
| | | |
| | | /** |
| | | * Render complete form from Meta instance |
| | | */ |
| | | public static function renderFormFrom(Meta $meta, string $endpoint, array $options = []): string |
| | | { |
| | | $id = $options['form-id'] ?? $endpoint; |
| | | $classes = isset($options['classes']) ? ' class="' . implode(' ', $options['classes']) . '"' : ''; |
| | | |
| | | $output = '<form id="' . esc_attr($endpoint) . '"' . $classes . ' data-save="' . esc_attr($endpoint) . '" data-form-id="' . esc_attr($id) . '">'; |
| | | |
| | | if (!empty($options['heading'])) { |
| | | $output .= '<h2>' . esc_html($options['heading']) . '</h2>'; |
| | | } |
| | | |
| | | if (!empty($options['description'])) { |
| | | $descriptions = is_array($options['description']) ? $options['description'] : [$options['description']]; |
| | | foreach ($descriptions as $d) { |
| | | $output .= '<p>' . esc_html($d) . '</p>'; |
| | | } |
| | | } |
| | | |
| | | foreach ($meta->configs() as $name => $config) { |
| | | $output .= static::render($name, $meta->get($name), $config); |
| | | } |
| | | |
| | | if (!empty($options['submit'])) { |
| | | $output .= '<button type="submit">' . jvbIcon('floppy-disk') . 'Save</button>'; |
| | | } |
| | | |
| | | $output .= '</form>'; |
| | | |
| | | return $output; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Helper Methods |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | protected static function fieldWrap(string $name, string $content, array $config): string |
| | | { |
| | | $classes = static::buildClasses($config); |
| | | $datasets = static::buildDatasets($config); |
| | | |
| | | if (!array_key_exists('type', $config)) { |
| | | error_log('Config without type: '.print_r($config, true)); |
| | | } |
| | | $output = sprintf( |
| | | '<div class="%s" data-field="%s" data-field-type="%s"%s>', |
| | | $classes, |
| | | $name, |
| | | $config['type'], |
| | | $datasets |
| | | ); |
| | | |
| | | $output .= static::buildLabel($name, $config); |
| | | if (!array_key_exists('skipInput', $config)) { |
| | | $output .= static::buildInput($content); |
| | | } |
| | | |
| | | $output .= static::buildHint($config); |
| | | $output .= static::buildDescription($name, $config); |
| | | |
| | | $output .= '</div>'; |
| | | |
| | | return $output; |
| | | } |
| | | protected static function buildClasses(array $config): string |
| | | { |
| | | $classes = ['field field-' . ($config['type'] ?? 'text')]; |
| | | if (!empty($config['required'])) { |
| | | $classes[] = 'required'; |
| | | } |
| | | if (!empty($config['class'])) { |
| | | if (!is_array($config['class'])) { |
| | | $config['class'] = [$config['class']]; |
| | | } |
| | | $classes = array_merge($classes, $config['class']); |
| | | } |
| | | |
| | | return trim(implode(' ',$classes)); |
| | | } |
| | | |
| | | protected static function buildDatasets(array $config): string |
| | | { |
| | | $datasets = static::handleCustomDatasets($config); |
| | | $datasets .= static::handleValidationLogic($config); |
| | | $datasets .= static::handleConditionalLogic($config); |
| | | return $datasets; |
| | | } |
| | | protected static function handleCustomDatasets($config):string |
| | | { |
| | | if (array_key_exists('data', $config) && !empty($config['data'])) { |
| | | $datasets = array_map(function ($key) use ($config) { |
| | | $name = str_replace('_', '-', sanitize_title($key)); |
| | | return ' data-'.$name.'="'.$config['data'][$key].'"'; |
| | | }, $config['data']); |
| | | |
| | | return implode($datasets); |
| | | } |
| | | return ''; |
| | | } |
| | | protected static function handleValidationLogic($config):string |
| | | { |
| | | $datasets = ''; |
| | | $dataAttrs = ['pattern', 'validate', 'min', 'max', 'minlength', 'maxlength', 'validation_message']; |
| | | $attrs = []; |
| | | foreach ($dataAttrs as $attr) { |
| | | if (array_key_exists($attr, $config) && !empty($config[$attr])) { |
| | | $attrs[$attr] = $config[$attr]; |
| | | } |
| | | } |
| | | foreach($attrs as $attr => $value) { |
| | | $datasets .= sprintf(' data-%s="%s"', $attr, esc_attr($value)); |
| | | } |
| | | return $datasets; |
| | | } |
| | | protected static function handleConditionalLogic($config):string |
| | | { |
| | | if (empty($config['condition'])) { |
| | | return ''; |
| | | } |
| | | |
| | | return sprintf( |
| | | 'data-depends-on="%s" data-depends-value="%s" data-depends-operator="%s"', |
| | | esc_attr($config['condition']['field']), |
| | | esc_attr($config['condition']['value']), |
| | | esc_attr($config['condition']['operator'] ?? '==') |
| | | ); |
| | | } |
| | | |
| | | protected static function buildLabel(string $name, array $config):string |
| | | { |
| | | if (!empty($config['label'])) { |
| | | return sprintf( |
| | | '<label for="%s">%s%s</label>', |
| | | esc_attr($name), |
| | | esc_html($config['label']), |
| | | !empty($config['required']) ? '<span class="required" aria-label="required">*</span>' : '' |
| | | ); |
| | | } |
| | | return ''; |
| | | } |
| | | |
| | | protected static function buildInput(string $content):string |
| | | { |
| | | return sprintf( |
| | | '<div class="field-input-wrapper"> |
| | | %s |
| | | <span class="validation-icon success" hidden aria-hidden="true">%s</span> |
| | | <span class="validation-icon error" hidden aria-hidden="true">%s</span> |
| | | </div><span class="validation-message" hidden role="alert"></span>', |
| | | $content, |
| | | jvbIcon('check-circle'), |
| | | jvbIcon('x-circle') |
| | | ); |
| | | } |
| | | |
| | | protected static function buildHint(array $config):string |
| | | { |
| | | if (!empty($config['hint'])) { |
| | | return sprintf( |
| | | '<span class="hint">%s</span>', |
| | | esc_html($config['hint']) |
| | | ); |
| | | } |
| | | return ''; |
| | | } |
| | | |
| | | protected static function buildDescription(string $name, array $config):string |
| | | { |
| | | if (!empty($config['description'])) { |
| | | return sprintf( |
| | | '<p class="description" id="%s">%s</p>', |
| | | esc_attr($name), |
| | | esc_html($config['description']) |
| | | ); |
| | | } |
| | | return ''; |
| | | } |
| | | |
| | | protected static function inputAttrs(string $name, array $config): string |
| | | { |
| | | $attrs = [ |
| | | 'id' => $name, |
| | | 'name' => $name, |
| | | ]; |
| | | |
| | | if (!empty($config['placeholder'])) { |
| | | $attrs['placeholder'] = $config['placeholder']; |
| | | } |
| | | if (!empty($config['autocomplete'])) { |
| | | $attrs['autocomplete'] = $config['autocomplete']; |
| | | } |
| | | if (isset($config['min'])) { |
| | | $attrs['min'] = $config['min']; |
| | | } |
| | | if (isset($config['max'])) { |
| | | $attrs['max'] = $config['max']; |
| | | } |
| | | if (isset($config['step'])) { |
| | | $attrs['step'] = $config['step']; |
| | | } |
| | | |
| | | |
| | | $html = ''; |
| | | //Add the attributes that stand on their own |
| | | $standalones = ['required', 'disabled', 'readonly','multiple']; |
| | | foreach($standalones as $s) { |
| | | if (array_key_exists($s, $config) && $config[$s] === true) { |
| | | $html .= ' '.$s; |
| | | } |
| | | } |
| | | |
| | | foreach ($attrs as $key => $val) { |
| | | |
| | | $html .= sprintf(' %s="%s"', $key, esc_attr($val)); |
| | | } |
| | | |
| | | return $html; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Type Renderers |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | protected static function renderText(string $name, mixed $value, array $config): string |
| | | { |
| | | $value = ($value === '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $input = sprintf( |
| | | '<input type="%s"%s%s />', |
| | | $config['subtype']??'text', |
| | | $value, |
| | | static::inputAttrs($name, $config) |
| | | ); |
| | | |
| | | return static::fieldWrap($name, $input, $config); |
| | | } |
| | | |
| | | protected static function renderTextarea(string $name, mixed $value, array $config): string |
| | | { |
| | | $rows = $config['rows'] ?? 5; |
| | | $quill = (array_key_exists('quill', $config) && $config['quill'] === true) ? ' data-editor="true"' : ''; |
| | | |
| | | if ($quill !== '') { |
| | | $allowImages = array_key_exists('allowImage', $config); |
| | | $quill .= ($allowImages) ? ' data-allowimage="true"' : ' data-allowimage="false"'; |
| | | } |
| | | |
| | | $textarea = sprintf( |
| | | '<textarea rows="%d"%s%s>%s</textarea>', |
| | | $rows, |
| | | static::inputAttrs($name, $config), |
| | | $quill, |
| | | esc_textarea($value) |
| | | ); |
| | | |
| | | return static::fieldWrap($name, $textarea, $config); |
| | | } |
| | | |
| | | protected static function renderNumber(string $name, mixed $value, array $config): string |
| | | { |
| | | $attrs = static::inputAttrs($name, $config); |
| | | |
| | | $value = ($value === '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $input = sprintf( |
| | | '<input type="number"%s%s />', |
| | | $value, |
| | | $attrs |
| | | ); |
| | | |
| | | return static::fieldWrap($name, $input, $config); |
| | | } |
| | | |
| | | protected static function renderQuantity(string $name, mixed $value, array $config): string |
| | | { |
| | | if (!array_key_exists('class', $config)) { |
| | | $config['class']=[]; |
| | | } |
| | | $config['class'][] ='quantity-input'; |
| | | |
| | | $attrs = static::inputAttrs($name, $config); |
| | | |
| | | $value = ($value === '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $input = sprintf( |
| | | '<div class="quantity"> |
| | | <button type="button" class="decrease" title="%s" aria-label="Decrease %s">%s</button> |
| | | <input type="number"%s%s /> |
| | | <button type="button" class="increase" title="%s" aria-label="Increase %s">%s</button> |
| | | </div>', |
| | | array_key_exists('remove', $config) ? $config['remove'] : 'Decrease amount', |
| | | array_key_exists('label', $config) ? $config['label'] : 'Amount', |
| | | jvbIcon('minus-square'), |
| | | $value, |
| | | $attrs, |
| | | array_key_exists('add', $config) ? $config['add'] : 'Increase amount', |
| | | array_key_exists('label', $config) ? $config['label'] : 'Amount', |
| | | jvbIcon('plus-square'), |
| | | ); |
| | | |
| | | return static::fieldWrap($name, $input, $config); |
| | | } |
| | | |
| | | protected static function renderEmail(string $name, mixed $value, array $config): string |
| | | { |
| | | $config['validate'] = 'email'; |
| | | $value = ($value === '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $input = sprintf( |
| | | '<input type="email"%s%s />', |
| | | $value, |
| | | static::inputAttrs($name, $config) |
| | | ); |
| | | |
| | | return static::fieldWrap($name, $input, $config); |
| | | } |
| | | |
| | | protected static function renderUrl(string $name, mixed $value, array $config): string |
| | | { |
| | | $config['validate'] = 'url'; |
| | | $value = ($value === '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $input = sprintf( |
| | | '<input type="url"%s%s />', |
| | | $value, |
| | | static::inputAttrs($name, $config) |
| | | ); |
| | | |
| | | return static::fieldWrap($name, $input, $config); |
| | | } |
| | | |
| | | protected static function renderTel(string $name, mixed $value, array $config): string |
| | | { |
| | | $config['validate'] = 'phone'; |
| | | $value = ($value === '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $input = sprintf( |
| | | '<input type="tel"%s%s />', |
| | | $value, |
| | | static::inputAttrs($name, $config) |
| | | ); |
| | | return static::fieldWrap($name, $input, $config); |
| | | } |
| | | |
| | | protected static function renderDate(string $name, mixed $value, array $config): string |
| | | { |
| | | $format = !empty($config['format']) ? $config['format'] : 'Y-m-d'; |
| | | |
| | | // Format the date if we have a value |
| | | if (!empty($value)) { |
| | | $date = DateTime::createFromFormat($format, $value); |
| | | if ($date) { |
| | | $value = $date->format('Y-m-d'); // HTML date input requires Y-m-d format |
| | | } |
| | | } |
| | | |
| | | $value = ($value === '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $input = sprintf( |
| | | '<input type="date"%s%s />', |
| | | $value, |
| | | static::inputAttrs($name, $config) |
| | | ); |
| | | |
| | | return static::fieldWrap($name, $input, $config); |
| | | } |
| | | |
| | | protected static function renderTime(string $name, mixed $value, array $config): string |
| | | { |
| | | $value = ($value === '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $input = sprintf( |
| | | '<input type="time"%s%s />', |
| | | $value, |
| | | static::inputAttrs($name, $config) |
| | | ); |
| | | |
| | | return static::fieldWrap($name, $input, $config); |
| | | } |
| | | |
| | | protected static function renderDatetime(string $name, mixed $value, array $config): string |
| | | { |
| | | $value = ($value === '') ? ' value="'.esc_attr($value).'"' : ''; |
| | | $input = sprintf( |
| | | '<input type="datetime-local"%s%s />', |
| | | $value, |
| | | static::inputAttrs($name, $config) |
| | | ); |
| | | |
| | | return static::fieldWrap($name, $input, $config); |
| | | } |
| | | |
| | | protected static function renderTrueFalse(string $name, mixed $value, array $config): string |
| | | { |
| | | if (!array_key_exists('class', $config)) { |
| | | $config['class'] = []; |
| | | } |
| | | $config['class'][] ='row btw'; |
| | | |
| | | $checked = filter_var($value, FILTER_VALIDATE_BOOLEAN); |
| | | |
| | | $input = sprintf( |
| | | '<label class="toggle-switch row"> |
| | | <input type="checkbox" value="1"%s%s /> |
| | | <div class="slider"></div> |
| | | <span class="toggle-label">%s</span> |
| | | </label>', |
| | | static::inputAttrs($name, $config), |
| | | $checked ? ' checked' : '', |
| | | array_key_exists('required', $config) && $config['required']===true ? '<span class="required" aria-label="required">*</span>' : '' |
| | | ); |
| | | |
| | | unset($config['label']); |
| | | return static::fieldWrap($name, $input, $config); |
| | | } |
| | | |
| | | protected static function renderToggleText($name, $value, $config):string |
| | | { |
| | | if (!isset($config['type'])) { |
| | | $config['type'] = 'toggle-text'; |
| | | } |
| | | $input = sprintf( |
| | | '<input type="checkbox" value="all"%s%s> |
| | | <label for="%s" class="row"> |
| | | %s |
| | | <span class="text row"> |
| | | <span class="off">%s</span> |
| | | <span class="on">%s</span> |
| | | </span> |
| | | %s |
| | | </label>', |
| | | static::inputAttrs($name, $config), |
| | | filter_var($value, FILTER_VALIDATE_BOOLEAN) ? ' checked' : '', |
| | | $name, |
| | | array_key_exists('before', $config) ? esc_html($config['before']) : '', |
| | | array_key_exists('off', $config) ? esc_html($config['off']) : 'Off', |
| | | array_key_exists('on', $config) ? esc_html($config['on']) : 'On', |
| | | array_key_exists('after', $config) ? esc_html($config['after']) : '', |
| | | ); |
| | | return static::fieldWrap($name, $input, $config); |
| | | } |
| | | |
| | | protected static function renderSelect(string $name, mixed $value, array $config): string |
| | | { |
| | | $options = $config['options'] ?? []; |
| | | |
| | | $optionsHtml = ''; |
| | | if (empty($config['required'])) { |
| | | $optionsHtml .= '<option value="">— Select —</option>'; |
| | | } |
| | | |
| | | foreach ($options as $optValue => $optLabel) { |
| | | $optionsHtml .= sprintf( |
| | | '<option value="%s"%s>%s</option>', |
| | | esc_attr($optValue), |
| | | selected($value, $optValue), |
| | | esc_html($optLabel) |
| | | ); |
| | | } |
| | | |
| | | $select = sprintf( |
| | | '<select%s>%s</select>', |
| | | static::inputAttrs($name, $config), |
| | | $optionsHtml |
| | | ); |
| | | |
| | | return static::fieldWrap($name, $select, $config); |
| | | } |
| | | |
| | | protected static function renderCheckbox(string $name, mixed $value, array $config): string |
| | | { |
| | | $options = $config['options'] ?? []; |
| | | $values = is_array($value) ? $value : explode(',', (string)$value); |
| | | $values = array_map('trim', $values); |
| | | |
| | | $checkboxes = sprintf( |
| | | '<fieldset> |
| | | <legend>%s%s</legend>', |
| | | esc_html($config['label'] ?? 'Select Option(s)'), |
| | | array_key_exists('required', $config) && $config['required']===true ? '<span class="required" aria-label="required">*</span>' : '' |
| | | ); |
| | | |
| | | foreach ($options as $optValue => $optLabel) { |
| | | $checked = in_array($optValue, $values) ? ' checked' : ''; |
| | | $checkboxes .= sprintf( |
| | | ' |
| | | <input type="checkbox" name="%s[]" id="%s-%s" value="%s"%s /> |
| | | <label class="checkbox-option" for="%s-%s"> |
| | | <span>%s</span> |
| | | </label>', |
| | | esc_attr($name), |
| | | esc_attr($name), |
| | | $optValue, |
| | | esc_attr($optValue), |
| | | $checked, |
| | | $name, |
| | | $optValue, |
| | | esc_html($optLabel) |
| | | ); |
| | | } |
| | | |
| | | $checkboxes .= '</fieldset>'; |
| | | |
| | | unset($config['label']); |
| | | return static::fieldWrap($name, $checkboxes, $config); |
| | | } |
| | | |
| | | protected static function renderRadio(string $name, mixed $value, array $config): string |
| | | { |
| | | $options = $config['options'] ?? []; |
| | | |
| | | $radios = sprintf( |
| | | '<fieldset> |
| | | <legend>%s%s</legend>', |
| | | array_key_exists('label', $config) ? esc_html($config['label']) : 'Select an option', |
| | | array_key_exists('required', $config) && $config['required']===true ? '<span class="required" aria-label="required">*</span>' : '' |
| | | ); |
| | | |
| | | foreach ($options as $optValue => $optLabel) { |
| | | $radios .= sprintf( |
| | | ' |
| | | <input type="radio" name="%s" value="%s"%s /> |
| | | <label class="radio-option" for="%s-%s"> |
| | | <span>%s</span> |
| | | </label>', |
| | | esc_attr($name), |
| | | esc_attr($optValue), |
| | | checked($value, $optValue), |
| | | $name, |
| | | $optValue, |
| | | esc_html($optLabel) |
| | | ); |
| | | } |
| | | |
| | | $radios .= '</fieldset>'; |
| | | |
| | | unset($config['label']); |
| | | return static::fieldWrap($name, $radios, $config); |
| | | } |
| | | |
| | | protected static function renderUpload(string $name, mixed $value, array $config): string |
| | | { |
| | | $defaults = [ |
| | | //File Type |
| | | 'subtype' => 'image', //'image', 'video', 'document', 'any' |
| | | 'accepted' => null, //null = use subtype defaults, or define an array of specific MIME types |
| | | //Upload Behavious |
| | | 'multiple' => false, //single or multiple uploads |
| | | 'limit' => 15, //Max number of uploads (0 = unlimited) |
| | | 'mode' => 'direct', // 'direct' or 'selection' TODO: unneeded? |
| | | 'destination'=> 'meta', //'meta', 'post', 'post_group' |
| | | //Processing Options |
| | | 'max_size' => null, //override default size limits |
| | | 'convert' => 'webp', //Image conversion format |
| | | 'quality' => 90, //Conversion quality |
| | | 'inputData' => [] |
| | | ]; |
| | | $config = array_merge($defaults, $config); |
| | | |
| | | // 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 ''; |
| | | } |
| | | $validate = [ |
| | | 'subtype' => ['image', 'video', 'document', 'any'], |
| | | 'mode' => ['direct', 'selection'], |
| | | 'destination'=> ['meta', 'post', 'post_group'] |
| | | ]; |
| | | foreach ($validate as $key => $options) { |
| | | if (!in_array($config[$key], $options)) { |
| | | error_log('Invalid option set for '.$key.': '.print_r($config[$key], true)); |
| | | return ''; |
| | | } |
| | | } |
| | | |
| | | $acceptAttr = implode(',',static::getAllowedTypes($config)); |
| | | |
| | | |
| | | //Add upload config to the datasets (handled by fieldWrap()) |
| | | $attrs = ['subtype', 'mode', 'destination', 'max_size']; |
| | | foreach ($attrs as $attr) { |
| | | $config['data'][$attr] = $config[$attr]; |
| | | } |
| | | $config['data']['upload-field'] = ''; |
| | | $config['data']['type'] = $config['multiple'] ? 'gallery' : 'single'; |
| | | if (!empty($config['content'])) { |
| | | $config['data']['content'] = $config['content']; |
| | | } |
| | | if ($config['limit'] > 0) { |
| | | $config['data']['limit'] = $config['limit']; |
| | | } |
| | | |
| | | $attachmentIds = static::parseIds($value); |
| | | |
| | | $input = sprintf( |
| | | '<div class="file-upload-container"> |
| | | <div class="file-upload-wrapper"> |
| | | <input type="file" |
| | | %s |
| | | accept="%s" |
| | | data-max-size="%s"> |
| | | <h2>%s</h2> |
| | | %s |
| | | <p class="file-upload-text"> |
| | | <strong>Click to upload</strong> or drag and drop<br> |
| | | %s |
| | | (max. %s) |
| | | </p>', |
| | | static::inputAttrs($name, $config), |
| | | $acceptAttr, |
| | | esc_attr(static::getMaxFileSize($config)), |
| | | array_key_exists('label', $config) ? esc_html($config['label']) : 'Upload file(s)', |
| | | static::buildDescription($name, $config), |
| | | esc_html(static::getAcceptedTypesLabel($config)), |
| | | esc_html(static::formatFileSize(static::getMaxFileSize($config))) |
| | | ); |
| | | $plural = (array_key_exists($config['content'], JVB_CONTENT)) ? JVB_CONTENT[$config['content']]['plural'] : (array_key_exists($config['content'], JVB_TAXONOMY) ? JVB_TAXONOMY[$config['content']]['plural'] : str_replace('_', ' ',$config['content']).'s'); |
| | | $singular = (array_key_exists($config['content'], JVB_CONTENT)) ? JVB_CONTENT[$config['content']]['singular'] : (array_key_exists($config['content'], JVB_TAXONOMY) ? JVB_TAXONOMY[$config['content']]['singular'] : str_replace('_', ' ',$config['content'])); |
| | | if ($config['destination'] === 'post_group') { |
| | | $input .= sprintf( |
| | | '<p class="hint">You can group images to create separate %s.</p> |
| | | <p class="hint">If a %s has multiple images, you can select the %s to set an image as the main one.</p>', |
| | | $plural, |
| | | $singular, |
| | | jvbIcon('star') |
| | | ); |
| | | } |
| | | |
| | | if (array_key_exists('upload_description', $config) && $config['upload_description']!==''){ |
| | | $input .= sprintf('<p>%s</p>', esc_html($config['upload_description'])); |
| | | } |
| | | |
| | | $input .= '<div class="file-error"></div>'; |
| | | $input .= jvbRenderProgressBar('', false, true, true); |
| | | $input .= '</div>'; |
| | | |
| | | if ($config['destination'] === 'post_group') { |
| | | $input .= static::renderUploadGroupAreaStart($config, $plural, $singular); |
| | | } |
| | | |
| | | $input .= static::renderUploadItem($attachmentIds, $config['subtype']); |
| | | if ($config['destination'] === 'post_group') { |
| | | $input .= static::renderUploadGroupAreaEnd($config, $plural, $singular); |
| | | } |
| | | |
| | | unset($config['description']); |
| | | unset($config['label']); |
| | | return static::fieldWrap($name, $input, $config); |
| | | } |
| | | protected static function renderUploadGroupAreaStart(array $config, string $plural='', string $singular = ''):string |
| | | { |
| | | return sprintf('<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" data-select-all data-selects="item-grid" 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"> |
| | | %sGroup |
| | | </button> |
| | | <button type="button" data-action="delete-upload"> |
| | | %sDelete |
| | | </button> |
| | | </div> |
| | | </div> |
| | | |
| | | <button type="button" data-action="upload" class="submit-uploads"> |
| | | %sUpload %s |
| | | </button> |
| | | </div>', |
| | | jvbIcon('plus-square'), |
| | | jvbIcon('trash'), |
| | | jvbIcon('cloud-arrow-up'), |
| | | $plural!==''? $plural : $config['content'], |
| | | ); |
| | | } |
| | | |
| | | protected static function renderUploadItem(array $attachmentIds, string $subtype):string |
| | | { |
| | | $out = jvbRenderProgressBar('<span class="text">Processing files...</span> |
| | | <span class="count">0/0</span>',false,true,true); |
| | | $out .= '<div class="item-grid preview">'; |
| | | // Render existing attachments |
| | | foreach ($attachmentIds as $attachmentId) { |
| | | $out .= static::renderExistingAttachment($attachmentId, $subtype); |
| | | } |
| | | $out .= '</div>'; |
| | | return $out; |
| | | } |
| | | |
| | | public static function renderExistingAttachment(int $attachmentId, string $subtype):string |
| | | { |
| | | switch ($subtype){ |
| | | case 'video': |
| | | return static::renderVideoPreview($attachmentId); |
| | | case 'document': |
| | | case 'file': |
| | | return static::renderFilePreview($attachmentId); |
| | | default: |
| | | return static::renderImagePreview($attachmentId); |
| | | } |
| | | } |
| | | protected static function renderUploadItemStart(?int $attachmentId = null):string |
| | | { |
| | | return sprintf( |
| | | '<div class="item upload" data-id="%d"> |
| | | <div class="preview"> |
| | | <input type="checkbox" class="select-item" name="select-item" id="select-item%d"> |
| | | <label for="select-item%d" aria-label="Select image">', |
| | | $attachmentId, |
| | | ($attachmentId) ? '-'.$attachmentId : '', |
| | | ($attachmentId) ? '-'.$attachmentId : '' |
| | | ); |
| | | } |
| | | protected static function renderUploadItemEnd():string { |
| | | return '</label>'; |
| | | } |
| | | protected static function renderUploadItemActions(?int $attachmentId = null):string |
| | | { |
| | | return sprintf( |
| | | '<div class="item-actions row btw"> |
| | | <div class="radio-button"> |
| | | <input type="radio" class="featured btn" name="featured" id="featured%d" hidden> |
| | | <label for="featured"> |
| | | %s%s<span class="screen-reader-text">Set as featured image</span> |
| | | </label> |
| | | </div> |
| | | <button type="button" data-action="delete-upload" title="Remove from Group"> |
| | | %s |
| | | </button> |
| | | </div>', |
| | | ($attachmentId) ? '-'.$attachmentId : '', |
| | | jvbIcon('star'), |
| | | jvbIcon('star', ['style' => 'fill']), |
| | | jvbIcon('trash') |
| | | ); |
| | | } |
| | | protected static function renderUploadItemMetaStart():string |
| | | { |
| | | return '</div>'; |
| | | } |
| | | protected static function renderUploadItemMetaEnd():string |
| | | { |
| | | return '</div>'; |
| | | } |
| | | protected static function renderVideoPreview(?int $ID = null, ?array $additionalFields = null):string |
| | | { |
| | | $out = static::renderUploadItemStart($ID); |
| | | //add video preview |
| | | $previewID = get_post_meta($ID, BASE.'poster', true); |
| | | if ($previewID !== '') { |
| | | $out .= jvbFormatImage($previewID, 'tiny', 'medium'); |
| | | } else { |
| | | $out .= '<img><video></video><span></span>'; |
| | | } |
| | | $out .= static::renderUploadItemEnd(); |
| | | //add item actions |
| | | $out .= static::renderUploadItemActions($ID); |
| | | $out .= static::renderUploadItemMetaStart(); |
| | | |
| | | //Caption, description, title |
| | | $caption = ($ID) ? wp_get_attachment_caption($ID) : ''; |
| | | $description = ($ID) ? get_the_content($ID) : ''; |
| | | $title = ($ID) ? get_the_title($ID) : ''; |
| | | |
| | | $fields = [ |
| | | 'type' => 'group', |
| | | 'wrap' => 'details', |
| | | 'label' => 'Edit Video Meta', |
| | | 'fields' => [ |
| | | 'image-title' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Video Title', |
| | | 'value' => $title, |
| | | 'data' => ['id' => $ID] |
| | | ], |
| | | 'poster' => [ |
| | | 'type' => 'upload', |
| | | 'label' => 'Video Poster', |
| | | 'value' => $previewID, |
| | | 'multiple' => false, |
| | | ], |
| | | 'image-caption' => [ |
| | | 'type' => 'textarea', |
| | | 'value' => $caption, |
| | | 'label' => 'Video Caption', |
| | | 'data' => ['id' => $ID] |
| | | ], |
| | | 'image-description' => [ |
| | | 'type' => 'textarea', |
| | | 'value' => $description, |
| | | 'label' => 'Video Description', |
| | | 'data' => ['id' => $ID] |
| | | ] |
| | | ] |
| | | ]; |
| | | |
| | | $out .= static::render('image_data', $fields); |
| | | $out .= static::renderUploadItemMetaEnd(); |
| | | |
| | | if ($additionalFields) { |
| | | $out .= static::additionalFields($additionalFields); |
| | | } |
| | | |
| | | return $out; |
| | | } |
| | | protected static function renderFilePreview(?int $ID, ?array $additionalFields = null):string |
| | | { |
| | | $out = static::renderUploadItemStart($ID); |
| | | |
| | | $upload = wp_get_attachment_url($ID); |
| | | $fileType = wp_check_filetype($upload)['ext']??false; |
| | | $iconMap = [ |
| | | 'pdf' => 'file-pdf', |
| | | 'csv' => 'file-csv', |
| | | 'doc' => 'file-doc', |
| | | 'docx' => 'file-doc', |
| | | 'txt' => 'file-txt', |
| | | 'xls' => 'file-xls', |
| | | 'xlsx' =>'file-xls' |
| | | ]; |
| | | $icon = ($fileType) ? jvbIcon($iconMap[$fileType]??'file') : jvbIcon('file'); |
| | | $out .= '<span>'.$icon.'</span>'; |
| | | |
| | | $out .= static::renderUploadItemEnd(); |
| | | //add item actions |
| | | $out .= static::renderUploadItemActions($ID); |
| | | $out .= static::renderUploadItemMetaStart(); |
| | | |
| | | //Caption, description, title |
| | | $caption = ($ID) ? wp_get_attachment_caption($ID) : ''; |
| | | $description = ($ID) ? get_the_content($ID) : ''; |
| | | $title = ($ID) ? get_the_title($ID) : ''; |
| | | |
| | | $fields = [ |
| | | 'type' => 'group', |
| | | 'wrap' => 'details', |
| | | 'label' => 'Edit File Meta', |
| | | 'fields' => [ |
| | | 'image-title' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'File Title', |
| | | 'value' => $title, |
| | | 'data' => ['id' => $ID] |
| | | ], |
| | | 'poster' => [ |
| | | 'type' => 'upload', |
| | | 'label' => 'File Poster', |
| | | 'multiple' => false, |
| | | ], |
| | | 'image-caption' => [ |
| | | 'type' => 'textarea', |
| | | 'value' => $caption, |
| | | 'label' => 'File Caption', |
| | | 'data' => ['id' => $ID] |
| | | ], |
| | | 'image-description' => [ |
| | | 'type' => 'textarea', |
| | | 'value' => $description, |
| | | 'label' => 'File Description', |
| | | 'data' => ['id' => $ID] |
| | | ] |
| | | ] |
| | | ]; |
| | | |
| | | $out .= static::render('image_data', $fields); |
| | | $out .= static::renderUploadItemMetaEnd(); |
| | | |
| | | if ($additionalFields) { |
| | | $out .= static::additionalFields($additionalFields); |
| | | } |
| | | |
| | | return $out; |
| | | } |
| | | public static function renderImagePreview(?int $ID = null, ?array $additionalFields = null):string |
| | | { |
| | | $out = static::renderUploadItemStart($ID); |
| | | //add image preview |
| | | if ($ID) { |
| | | $out .= jvbFormatImage($ID, 'tiny', 'medium'); |
| | | } else { |
| | | $out .= '<img><video></video><span></span>'; |
| | | } |
| | | $out .= static::renderUploadItemEnd(); |
| | | //add item actions |
| | | $out .= static::renderUploadItemActions($ID); |
| | | $out .= static::renderUploadItemMetaStart(); |
| | | |
| | | //Caption, description, title |
| | | $caption = ($ID) ? wp_get_attachment_caption($ID) : ''; |
| | | $description = ($ID) ? get_the_content($ID) : ''; |
| | | $alt = ($ID) ? get_post_meta($ID, '_wp_attachment_image_alt', true) : ''; |
| | | $title = ($ID) ? get_the_title($ID) : ''; |
| | | |
| | | $fields = [ |
| | | 'type' => 'group', |
| | | 'wrap' => 'details', |
| | | 'label' => 'Edit Image Meta', |
| | | 'fields' => [ |
| | | 'image-title' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Image Title', |
| | | 'value' => $title, |
| | | 'data' => ['id' => $ID] |
| | | ], |
| | | 'image-alt-text' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Alt Text', |
| | | 'value' => $alt, |
| | | 'hint' => 'Alt text helps the visually impaired, as well as some benefits for SEO.', |
| | | 'data' => ['id' => $ID] |
| | | ], |
| | | 'image-caption' => [ |
| | | 'type' => 'textarea', |
| | | 'value' => $caption, |
| | | 'label' => 'Image Caption', |
| | | 'data' => ['id' => $ID] |
| | | ], |
| | | 'image-description' => [ |
| | | 'type' => 'textarea', |
| | | 'value' => $description, |
| | | 'label' => 'Image Description', |
| | | 'data' => ['id' => $ID] |
| | | ] |
| | | ] |
| | | ]; |
| | | |
| | | $out .= static::render('image_data', $fields); |
| | | |
| | | $out .= static::renderUploadItemMetaEnd(); |
| | | if ($additionalFields) { |
| | | $out .= static::additionalFields($additionalFields); |
| | | } |
| | | |
| | | return $out; |
| | | } |
| | | protected static function additionalFields(array $fields):string |
| | | { |
| | | $out = ''; |
| | | foreach ($fields as $name => $config) { |
| | | $out .= static::render($name, '', $config); |
| | | } |
| | | return $out; |
| | | } |
| | | |
| | | protected static function renderUploadGroupAreaEnd(array $config, string $plural, string $singular):string |
| | | { |
| | | return sprintf( |
| | | '<p class="hint">%s These will become individual %s %s</p> |
| | | </div> |
| | | <div class="sidebar flex col"> |
| | | <div class="header"> |
| | | <h4>New %s</h4> |
| | | <p class="hint">Drag or select multiple images into groups to create separate %s.</p> |
| | | </div> |
| | | <div class="item-grid groups"> |
| | | <div class="empty-group"> |
| | | <p>Drag here to create a new %s!</p> |
| | | </div> |
| | | </div> |
| | | <p class="hint">%s Each group will become its own %s %s</p> |
| | | </div> |
| | | </div>', |
| | | jvbIcon('arrow-elbow-left-up'), |
| | | $plural, |
| | | jvbIcon('arrow-elbow-right-up'), |
| | | $plural, |
| | | $plural, |
| | | $singular, |
| | | jvbIcon('arrow-elbow-left-up'), |
| | | $singular, |
| | | jvbIcon('arrow-elbow-left-up') |
| | | ); |
| | | } |
| | | protected static function getAllowedTypes(array $config): array |
| | | { |
| | | if (!empty($config['accepted'])) { |
| | | return $config['accepted']; |
| | | } |
| | | $defaults = [ |
| | | 'image' => ['image/*'], |
| | | 'video' => ['video/*'], |
| | | 'document' => ['application/pdf', 'application/msword', 'application/vnd.ms-excel', 'text/plain', '.odt','application/vnd.openxmlformats-officedocument.wordprocessingml.document'], |
| | | ]; |
| | | $defaults['any'] = array_merge(array_values($defaults)); |
| | | return $defaults[$config['subtype']]??$defaults['image']; |
| | | } |
| | | |
| | | protected static function getMaxFileSize(array $config):string |
| | | { |
| | | |
| | | $defaults = [ |
| | | 'image' => 5242880, // 5MB |
| | | 'video' => 104857600, // 100MB |
| | | 'document' => 10485760 // 10MB |
| | | ]; |
| | | |
| | | return absint($config['max_size']??$defaults[$config['subtype']] ?? $defaults['image']); |
| | | } |
| | | |
| | | protected static 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'; |
| | | } |
| | | |
| | | protected static function getAcceptedTypesLabel(array $config):string |
| | | { |
| | | $labels = [ |
| | | 'image' => 'JPG, JPEG, PNG, GIF, or WEBP', |
| | | 'video' => 'MP4, WEBM, or MOV', |
| | | 'document'=> 'PDF, DOC, XLS, or TXT', |
| | | 'any' => 'Images, Videos, or Documents' |
| | | ]; |
| | | |
| | | return $labels[$config['subtype']] ?? strtoupper(implode(', ', array_map(function($ext) { |
| | | return ltrim($ext, '.'); |
| | | }, array_slice($config['accepted'], 0, 3)))); |
| | | } |
| | | |
| | | protected static function parseIds(mixed $value):array |
| | | { |
| | | if (empty($value)) { |
| | | return []; |
| | | } |
| | | if (is_string($value)) { |
| | | $value = explode(',',$value); |
| | | } |
| | | return array_filter(array_map('absint', $value), function($item) { |
| | | return $item > 0; |
| | | }); |
| | | } |
| | | |
| | | protected static function renderGallery(string $name, mixed $value, array $config): string |
| | | { |
| | | $config['multiple'] = true; |
| | | return static::renderUpload($name,$value,$config); |
| | | } |
| | | |
| | | protected static function renderSelector(string $name, mixed $value, array $config, string $extra =''):string |
| | | { |
| | | $ids = static::parseIds($value); |
| | | |
| | | if (!array_key_exists('subtype', $config) || !in_array($config['subtype'], ['taxonomy', 'content', 'user'])){ |
| | | error_log('Invalid subtype for Selector: '.print_r($config['subtype']??false, true)); |
| | | return ''; |
| | | } |
| | | |
| | | $config = array_merge([ |
| | | 'max' => $config['max']??0, |
| | | 'search' => $config['search']??true, |
| | | 'createNew' => $config['createNew']??false, |
| | | 'autocomplete'=> $config['autocomplete']??true, |
| | | 'name' => $name, |
| | | 'update' => $config['update']??true, |
| | | 'required' => $config['required']??false, |
| | | 'type' => $config['subtype'], |
| | | ], $config); |
| | | |
| | | $icon = match ($config['subtype']) { |
| | | 'taxonomy' => JVB_TAXONOMY[$config['taxonomy']]['icon'] ?? jvbDefaultIcon(), |
| | | 'content' => JVB_CONTENT[$config['content']]['icon'] ?? jvbDefaultIcon(), |
| | | 'user' => JVB_USER[$config['role']]['icon'] ?? 'user', |
| | | default => jvbDefaultIcon(), |
| | | }; |
| | | |
| | | $containerId = sprintf('%s-%s-selector', $name, $config['subtype']); |
| | | |
| | | $input = sprintf( |
| | | '<div class="row btw"> |
| | | <label for="%s-autocomplete">%s<span>%s</span></label>', |
| | | esc_attr($name), |
| | | jvbIcon($icon), |
| | | esc_html($config['label']), |
| | | ); |
| | | |
| | | $input .= static::buildSelectorButton($ids, $config); |
| | | |
| | | if ($config['autocomplete']) { |
| | | $input .= static::buildSelectorAutocomplete($name, $config); |
| | | } |
| | | $plural = static::getPlural($config); |
| | | $input .= sprintf( |
| | | '<div class="selected-item row" role="region" aria-label="Selected %s"></div>', |
| | | $plural[1]??'' |
| | | ); |
| | | |
| | | unset($config['label']); |
| | | unset($config['description']); |
| | | unset($config['hint']); |
| | | $config['skipInput'] = true; |
| | | return static::fieldWrap($containerId, $input, $config); |
| | | } |
| | | |
| | | protected static function getPlural(array $config):array |
| | | { |
| | | switch ($config['subtype']) { |
| | | case 'taxonomy': |
| | | if (array_key_exists($config['taxonomy'], JVB_TAXONOMY)) { |
| | | $single = JVB_TAXONOMY[$config['taxonomy']]['singular']; |
| | | $plural = JVB_TAXONOMY[$config['taxonomy']]['plural']; |
| | | } else { |
| | | $taxonomy = get_taxonomy($config['taxonomy']); |
| | | if (!$taxonomy) { |
| | | return []; |
| | | } |
| | | $single = $taxonomy->labels->singular_name; |
| | | $plural = $taxonomy->labels->name; |
| | | } |
| | | break; |
| | | case 'content': |
| | | if (array_key_exists($config['content'], JVB_CONTENT)) { |
| | | $single = JVB_CONTENT[$config['content']]['singular']; |
| | | $plural = JVB_CONTENT[$config['content']]['plural']; |
| | | } else { |
| | | $postType = get_post_type_object($config['content']); |
| | | if (!$postType) { |
| | | return ''; |
| | | } |
| | | $single = $postType->labels->singular_name; |
| | | $plural = $postType->labels->name; |
| | | } |
| | | break; |
| | | |
| | | case 'user': |
| | | if (array_key_exists($config['user'], JVB_USER)) { |
| | | $single = JVB_USER[$config['user']]['singular']; |
| | | $plural = JVB_USER[$config['user']]['plural']; |
| | | } else { |
| | | $user = get_role($config['user']); |
| | | if (!$user) { |
| | | return ''; |
| | | } |
| | | $single = 'User'; |
| | | $plural = 'Users'; |
| | | } |
| | | break; |
| | | } |
| | | return [$single, $plural]; |
| | | } |
| | | |
| | | protected static function buildSelectorButton(array $ids, array $config):string |
| | | { |
| | | switch ($config['subtype']) { |
| | | case 'taxonomy': |
| | | if (array_key_exists($config['taxonomy'], JVB_TAXONOMY)) { |
| | | $single = JVB_TAXONOMY[$config['taxonomy']]['singular']; |
| | | $plural = JVB_TAXONOMY[$config['taxonomy']]['plural']; |
| | | } else { |
| | | $taxonomy = get_taxonomy($config['taxonomy']); |
| | | if (!$taxonomy) { |
| | | return ''; |
| | | } |
| | | $single = $taxonomy->labels->singular_name; |
| | | $plural = $taxonomy->labels->name; |
| | | } |
| | | $attr = sprintf( |
| | | ' data-taxonomy="%s" data-single="%s" data-plural="%s', |
| | | $config['taxonomy'], |
| | | $single, |
| | | $plural |
| | | ); |
| | | break; |
| | | case 'content': |
| | | if (array_key_exists($config['content'], JVB_CONTENT)) { |
| | | $single = JVB_CONTENT[$config['content']]['singular']; |
| | | $plural = JVB_CONTENT[$config['content']]['plural']; |
| | | } else { |
| | | $postType = get_post_type_object($config['content']); |
| | | if (!$postType) { |
| | | return ''; |
| | | } |
| | | $single = $postType->labels->singular_name; |
| | | $plural = $postType->labels->name; |
| | | } |
| | | $attr = sprintf( |
| | | ' data-content="%s" data-single="%s" data-plural="%s', |
| | | $config['content'], |
| | | $single, |
| | | $plural |
| | | ); |
| | | break; |
| | | |
| | | case 'user': |
| | | if (array_key_exists($config['user'], JVB_USER)) { |
| | | $single = JVB_USER[$config['user']]['singular']; |
| | | $plural = JVB_USER[$config['user']]['plural']; |
| | | } else { |
| | | $user = get_role($config['user']); |
| | | if (!$user) { |
| | | return ''; |
| | | } |
| | | $single = 'User'; |
| | | $plural = 'Users'; |
| | | } |
| | | $attr = sprintf( |
| | | ' data-user="%s" data-single="%s" data-plural="%s', |
| | | $config['user'], |
| | | $single, |
| | | $plural |
| | | ); |
| | | break; |
| | | } |
| | | |
| | | $dataAttrs = []; |
| | | if ($config['update']) { |
| | | $dataAttrs[] = 'data-update="false"'; |
| | | } |
| | | if ($config['max']>0) { |
| | | $dataAttrs[] = 'data-max="'.esc_attr($config['max']).'"'; |
| | | } |
| | | if ($config['search']) { |
| | | $dataAttrs[] = 'data-search'; |
| | | } |
| | | if ($config['createNew']) { |
| | | $dataAttrs[] = 'data-creatable'; |
| | | } |
| | | if (array_key_exists('types', $config) && is_array($config['types'])) { |
| | | $dataAttrs[] = 'data-for="'.esc_attr(implode(',',$config['types'])).'"'; |
| | | } |
| | | if (!empty($selected)) { |
| | | $dataAttrs[] = 'data-selected="'.esc_attr(implode(',',$selected)).'"'; |
| | | } |
| | | if ($config['autocomplete']) { |
| | | $dataAttrs[] = 'autocomplete'; |
| | | } |
| | | if (array_key_exists('hidden', $config) && $config['hidden']) { |
| | | $dataAttrs[] = 'hidden'; |
| | | } |
| | | |
| | | $dataAttrs = implode(' ',$dataAttrs); |
| | | |
| | | return sprintf( |
| | | '<button type="button" class="filter-toggle selector-toggle"%s%s title="Open %s Selector" aria-label="Select %s">%s</button>', |
| | | $attr, |
| | | $dataAttrs, |
| | | $single, |
| | | $plural, |
| | | jvbIcon('plus-square') |
| | | ); |
| | | } |
| | | protected static function buildSelectorAutocomplete(string $name, array $config):string |
| | | { |
| | | return sprintf( |
| | | '<input type="hidden" id="%s-autocomplete" autocomplete="off" data-ignore data-autocomplete> |
| | | <p class="message" hidden aria-live="polite">{ <span>Loading items</span> }</p> |
| | | <div class="auto-wrapper" hidden><ul class="search-results"></ul><button class="submit-term" hidden data-ignore><strong>Create: </strong> "<span></span>"</button></div>', |
| | | $name |
| | | ); |
| | | } |
| | | |
| | | protected static function renderTaxonomy(string $name, mixed $value, array $config): string |
| | | { |
| | | $config['subtype'] = 'taxonomy'; |
| | | return static::renderSelector($name, $value, $config); |
| | | } |
| | | |
| | | protected static function renderUser(string $name, mixed $value, array $config): string |
| | | { |
| | | $config['subtype'] = 'user'; |
| | | return static::renderSelector($name, $value, $config); |
| | | } |
| | | protected static function renderContent(string $name, mixed $value, array $config): string |
| | | { |
| | | $config['subtype'] = 'content'; |
| | | return static::renderSelector($name, $value, $config); |
| | | } |
| | | |
| | | protected static function renderLocation(string $name, mixed $value, array $config): string |
| | | { |
| | | $googleMaps = JVB()->connect('maps'); |
| | | if (!$googleMaps->isSetUp()) { |
| | | return '<div class="notice notice-warning"><p>Google Maps not configured. Please configure in Integrations settings.</p></div>'; |
| | | } |
| | | |
| | | $field_id = esc_attr($name); |
| | | $map_id = sprintf('%s_map', $field_id); |
| | | $components = ['address', 'lat', 'lng', 'street', 'city', 'province', 'postal_code', 'country']; |
| | | if (!empty($value)) { |
| | | $lat = (float)$value['lat']??''; |
| | | $lng = (float)$value['lng']??''; |
| | | |
| | | $coords = [ |
| | | 'lat' => $lat, |
| | | 'ng' => $lng |
| | | ]; |
| | | } else { |
| | | $coords = null; |
| | | } |
| | | if (!array_key_exists('data', $config)) { |
| | | $config['data'] =[]; |
| | | } |
| | | $js_config = [ |
| | | 'fieldId' => $field_id, |
| | | 'initialCoords' => $coords |
| | | ]; |
| | | |
| | | $json_config = htmlspecialchars(json_encode($js_config), ENT_QUOTES, 'UTF-8'); |
| | | $config['data']['location-field-init'] = $json_config; |
| | | |
| | | $input = ''; |
| | | if (!empty($value) && array_key_exists('address', $value)) { |
| | | $input = sprintf( |
| | | '<p><b>Current location:</b> %s</p><p class="hint">Search below to change:</p>', |
| | | esc_html($value['address']) |
| | | ); |
| | | } |
| | | $links = (!empty($value)) ? jvbLocationLinks($value) : ''; |
| | | $input .= sprintf( |
| | | '<div class="location-search-wrapper"> |
| | | <div class="autocomplete-wrapper"></div> |
| | | <div class="location-preview"> |
| | | <div id="%s" class="location-map"></div> |
| | | %s |
| | | </div>', |
| | | esc_attr($map_id), |
| | | $links |
| | | ); |
| | | |
| | | if (!empty($value)) { |
| | | foreach($components as $el) { |
| | | $input .= sprintf( |
| | | '<input type="hidden" |
| | | name="%s[%s]" |
| | | value="%s" |
| | | data-location-field="%s">', |
| | | esc_attr($name), |
| | | $el, |
| | | $value[$el]??'', |
| | | $el |
| | | ); |
| | | } |
| | | } |
| | | |
| | | $input .= '</div>'; |
| | | |
| | | return static::fieldWrap($name, $input, $config); |
| | | } |
| | | |
| | | protected static function renderTagList(string $name, mixed $value, array $config):string |
| | | { |
| | | $tagFormat = $config['tag_format']??'first_field'; |
| | | if (!array_key_exists('data', $config)) { |
| | | $config['data']= []; |
| | | } |
| | | $config['data']['tag-format'] = esc_attr($tagFormat); |
| | | |
| | | $input = sprintf( |
| | | '<h3>%s</h3><div class="row start wrap">', |
| | | esc_html($config['label']??'') |
| | | ); |
| | | |
| | | foreach ($config['fields'] as $fieldName => $fieldConfig) { |
| | | $newName = sprintf('new_%s', $fieldName); |
| | | if (array_key_exists('required', $fieldConfig)) { |
| | | $fieldConfig['data']['required'] = true; |
| | | unset($fieldConfig['required']); |
| | | } |
| | | $fieldConfig['data']['ignore'] = true; |
| | | |
| | | $input .= static::render($newName, '', $fieldConfig); |
| | | } |
| | | $input .= sprintf( |
| | | '<button type="button" class="button add-tag-item">%s<span>%s</span></button></div>', |
| | | jvbIcon('plus'), |
| | | $field['add_label']??'Add' |
| | | ); |
| | | |
| | | //Tag Display |
| | | $input .= '<div class="tag-items">'.static::renderTagItem($config['fields'], $value, $name, null, $tagFormat).'</div>'; |
| | | |
| | | //Template for tags |
| | | $input .= sprintf( |
| | | '<template class="%s">%s</template>', |
| | | uniqid('tagListItem'), |
| | | static::renderTagItem($config['fields'], [], null, $name, $tagFormat) |
| | | ); |
| | | |
| | | return static::fieldWrap($name, $input, $config); |
| | | } |
| | | protected static function renderTagItem(array $fields, mixed $values, string $name, ?int $index, string $tagFormat):string |
| | | { |
| | | $tagText = static::getTagDisplayText($fields, $values, $tagFormat); |
| | | |
| | | $out = sprintf( |
| | | '<div class="tag-item"%s><span class="tag-label">%s</span>', |
| | | ($index) ? ' data-index="'.$index.'"' : '', |
| | | $tagText |
| | | ); |
| | | |
| | | foreach ($fields as $fieldName => $fieldConfig) { |
| | | $value = $values[$fieldName]??''; |
| | | $fullName = (!$index) ? $fieldName : sprintf('%s:%s:%s', $name, $index, $fieldName); |
| | | |
| | | $out .= sprintf( |
| | | '<input type="hidden" |
| | | name="%s" |
| | | value="%s" |
| | | data-field="%s", |
| | | data-field-type="%s" />', |
| | | esc_attr($fullName), |
| | | esc_attr($value), |
| | | esc_attr($fieldName), |
| | | esc_attr($fieldConfig['type']) |
| | | ); |
| | | |
| | | $out .= sprintf( |
| | | '<button type="button" class="remove-tag" aria-label="Remove">%s</button></div>', |
| | | jvbIcon('x') |
| | | ); |
| | | } |
| | | return $out; |
| | | } |
| | | protected static function getTagDisplayText(array $fields, mixed $values, string $tagFormat):string |
| | | { |
| | | if (empty($data)) { |
| | | return 'New Item'; |
| | | } |
| | | |
| | | switch ($tagFormat) { |
| | | case 'first_field': |
| | | $firstKey = array_key_first($fields); |
| | | return $values[$firstKey] ?? 'New Item'; |
| | | case 'all_fields': |
| | | $values = array_filter(array_values($data)); |
| | | return implode(', ', $values) ?: 'New Item'; |
| | | default: |
| | | if (strpos($tagFormat, '{') !== false) { |
| | | $text = $tagFormat; |
| | | foreach ($values as $key => $value) { |
| | | $text = str_replace('{'.$key.'}', $value, $text); |
| | | } |
| | | return $text; |
| | | } |
| | | return $values[$tagFormat]??'New Item'; |
| | | } |
| | | } |
| | | |
| | | protected static function renderRepeater(string $name, mixed $value, array $config): string |
| | | { |
| | | $fields = $config['fields'] ?? []; |
| | | $rows = is_array($value) ? $value : []; |
| | | if(array_key_exists('row_label', $config)) { |
| | | $config['data']['label'] = esc_attr($config['row_label']); |
| | | } |
| | | |
| | | $input = sprintf( |
| | | '<h3>%s</h3>', |
| | | esc_html($config['label'] ?? '') |
| | | ); |
| | | |
| | | $input .= '<div class="repeater-items">'; |
| | | $rowTitle = array_key_exists('new_row', $config) ? $config['new_row'] : 'New Item'; |
| | | if(!empty($rows)) { |
| | | foreach ($rows as $index=>$row) { |
| | | $input .= static::renderRepeaterRow($config['fields'], $row, $index, $name, $rowTitle); |
| | | } |
| | | } |
| | | $input .= '</div>'; |
| | | |
| | | $input .= '<template class="'.uniqid('repeaterRow').'">'; |
| | | $input .= static::renderRepeaterRow($config['fields'], [], '','',$rowTitle); |
| | | $input .= '</template>'; |
| | | |
| | | $input .= sprintf( |
| | | '<button type="button" class="add-repeater-row">%s%s</button>', |
| | | jvbIcon('plus', ['title'=>'Add']), |
| | | array_key_exists('add_label', $config) ? $config['add_label'] : 'Add Item' |
| | | ); |
| | | |
| | | return static::fieldWrap($name, $input, $config); |
| | | } |
| | | protected static function renderRepeaterRow(array $fields, array $values, int|string $index, string $name, string $rowTitle='New Item'):string |
| | | { |
| | | $displayNumber = (is_string($index)) ? $index : ($index +1); |
| | | |
| | | $output = sprintf( |
| | | '<div class="repeater-row" data-index="%s"> |
| | | <button type="button" class="remove-row" title="Remove"> |
| | | %s |
| | | </button> |
| | | <details%s> |
| | | <summary class="row btw repeater-row-header"> |
| | | <span class="drag-handle">%s</span> |
| | | <span class="row-number">#%s</span> |
| | | <span class="row-title">%s</span> |
| | | </summary> |
| | | <div class="repeater-row-content">', |
| | | esc_attr($index), |
| | | jvbIcon('trash'), |
| | | is_string($index) ? ' open' : '', |
| | | jvbIcon('dots-six-vertical'), |
| | | $displayNumber, |
| | | $rowTitle |
| | | ); |
| | | foreach ($fields as $fieldName => $fieldConfig) { |
| | | $fieldValue = $values[$fieldName] ?? ''; |
| | | $fullName = ($name === '') ? $fieldName : sprintf('%s:%s:%s', $name, $index, $fieldName); |
| | | $output .= static::render($fullName, $fieldValue, $fieldConfig); |
| | | } |
| | | |
| | | return $output.'</div></details></div>'; |
| | | } |
| | | |
| | | /** |
| | | * Group fields are mainly for ease of conditional logic and visual layout |
| | | * @param string $name |
| | | * @param mixed $value |
| | | * @param array $config |
| | | * @return string |
| | | */ |
| | | protected static function renderGroup(string $name, mixed $value, array $config): string |
| | | { |
| | | $fields = $config['fields'] ?? []; |
| | | error_log('Group fields: '.print_r($fields, true)); |
| | | $values = is_array($value) ? $value : []; |
| | | |
| | | $wrapper = (array_key_exists('wrap', $config)) ? 'details' : 'fieldset'; |
| | | $legend = (array_key_exists('wrap', $config)) ? 'summary' : 'legend'; |
| | | |
| | | $output = sprintf( |
| | | '<%s><%s>%s</%s>' |
| | | , |
| | | esc_attr($wrapper), |
| | | esc_attr($legend), |
| | | array_key_exists('label', $config) ? $config['label'] : 'Group', |
| | | esc_attr($legend) |
| | | ); |
| | | |
| | | foreach ($fields as $fieldName => $fieldConfig) { |
| | | $fieldValue = $values[$fieldName] ?? ''; |
| | | $fullName = "{$name}:{$fieldName}"; |
| | | $output .= static::render($fullName, $fieldValue, $fieldConfig); |
| | | } |
| | | |
| | | $output .= sprintf('</%s>', esc_attr($wrapper)); |
| | | |
| | | return static::fieldWrap($name, $output, $config); |
| | | } |
| | | |
| | | public static function outputSelectorModal():string |
| | | { |
| | | return sprintf('<dialog id="jvb-selector" aria-labelledby="modal-title" aria-modal="true"> |
| | | <div class="wrap col"> |
| | | <header class="modal-header"> |
| | | <h3 id="modal-title">Select Taxonomy</h3> |
| | | </header> |
| | | |
| | | |
| | | <div class="selected-items row" role="region" aria-label="Selected items"></div> |
| | | |
| | | <div class="items-wrap"> |
| | | <!-- Common/Favorite terms section --> |
| | | <details class="favourite-terms" hidden> |
| | | <summary class="title row btw">Your Go Tos:</summary> |
| | | <ul class="favourite-list row btw"></ul> |
| | | </details> |
| | | |
| | | <!-- Pagination info --> |
| | | <p class="pagination-info" hidden></p> |
| | | |
| | | <!-- Navigation breadcrumbs --> |
| | | <nav class="term-navigation row" aria-label="Term navigation"> |
| | | <button type="button" class="back-to-parent" hidden> |
| | | <span aria-hidden="true">←</span> Back |
| | | </button> |
| | | </nav> |
| | | |
| | | |
| | | <p class="message" hidden aria-live="polite"> |
| | | { <span>loading items</span> } |
| | | </p> |
| | | <!-- Terms list --> |
| | | <ul class="items-container col start" role="listbox" aria-label="Available terms"> |
| | | <!-- Terms will be populated here --> |
| | | </ul> |
| | | |
| | | <button class="submit-term" hidden data-ignore><strong>Create: </strong> "<span></span>"</button> |
| | | |
| | | <!-- Infinite scroll sentinel --> |
| | | <div class="scroll-sentinel" aria-hidden="true"></div> |
| | | </div> |
| | | |
| | | <!-- Search section --> |
| | | <div class="search-wrapper"> |
| | | <div class="search-bar"> |
| | | %s |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- Create new term section --> |
| | | <details class="create-term" hidden> |
| | | <summary class="row btw">Add New Term</summary> |
| | | <div class="create-new-term-section"> |
| | | <form class="create-term" data-nocache data-form-id="create-term" data-save="terms"> |
| | | <div class="form-group"> |
| | | <label for="term_name">Term Name:</label> |
| | | <input type="text" name="term_name" id="term_name" required> |
| | | </div> |
| | | |
| | | <div class="form-group"> |
| | | <label for="select_parent">Nest it under:</label> |
| | | <select name="parent" id="select_parent"> |
| | | <option value="0">None (Top Level)</option> |
| | | </select> |
| | | </div> |
| | | </form> |
| | | |
| | | </div> |
| | | </details> |
| | | %s |
| | | </div> |
| | | </dialog> |
| | | <template class="loadingItems"> |
| | | <p>{ <span>loading items</span> }</p> |
| | | </template> |
| | | <template class="autocompleteItem"> |
| | | <li class="autocomplete item btn"></li> |
| | | </template> |
| | | <template class="noTermResults"> |
| | | <p>{ <span>nothing found</span> }</p> |
| | | </template> |
| | | <template class="termListItem"> |
| | | <li> |
| | | <input type="checkbox"> |
| | | <label> |
| | | <span class="term-name"></span> |
| | | </label> |
| | | </li> |
| | | </template> |
| | | <template class="termChildrenToggle"> |
| | | <button type="button" class="toggle-children" aria-expanded="false"> |
| | | %s |
| | | </button> |
| | | </template> |
| | | <template class="selectedTerm"> |
| | | <div class="selected-item row"> |
| | | <span class="item-name"></span> |
| | | <button type="button" class="remove-term row">%s</button> |
| | | </div> |
| | | </template> |
| | | <template class="termBreadcrumb"> |
| | | <button type="button" class="path-level"></button> |
| | | </template>', |
| | | static::search('Search terms', 'search-terms'), |
| | | jvbModalActions(), |
| | | jvbIcon('plus-square'), |
| | | jvbIcon('x') |
| | | ); |
| | | } |
| | | |
| | | public static function search(string $placeholder = 'Search...', string $id = 'search'):string |
| | | { |
| | | $id = sanitize_title($id); |
| | | return sprintf( |
| | | '<div class="search-container row start nowrap"> |
| | | <input type="search" id="%s" placeholder="%s"> |
| | | <button title="Clear Search" type="button" class="clear-search" aria-label="Clear search" |
| | | onclick="this.previousElementSibling.value = \'\'; this.previousElementSibling.focus();">%s</button> |
| | | <button type="button" title="Search" class="toggle search" aria-label="Toggles search input visually" onclick="this.parentNode.classList.toggle(\'open\');this.previousElementSibling.previousElementSibling.focus();">%s</button> |
| | | </div>', |
| | | $id, |
| | | $placeholder, |
| | | jvbIcon('x', ['title' => 'Clear Search']), |
| | | jvbIcon('magnifying-glass') |
| | | ); |
| | | } |
| | | } |
| | |
| | | public ?string $contentType; // tattoo, artist, style (without BASE prefix) |
| | | public ?object $wpObject; // WP_Post, WP_Term, WP_User |
| | | |
| | | /** @var array<string, Field> */ |
| | | /** @var array<string, Field> Loaded fields */ |
| | | public array $fields = []; |
| | | |
| | | /** @var array<string, array> Raw field configs from registry */ |
| | | public array $fieldConfigs = []; |
| | | |
| | | public ?string $baseKey = null; // For options |
| | | /** @var string|null Base key for options storage */ |
| | | public ?string $baseKey = null; |
| | | |
| | | /** |
| | | * WordPress default fields by object type |
| | | */ |
| | | public const WP_DEFAULTS = [ |
| | | 'post' => [ |
| | | 'post_title', |
| | |
| | | $this->contentType = $contentType; |
| | | } |
| | | |
| | | /** |
| | | * Check if field exists in configs |
| | | */ |
| | | public function hasField(string $name): bool |
| | | { |
| | | return isset($this->fields[$name]) || isset($this->fieldConfigs[$name]); |
| | | } |
| | | |
| | | /** |
| | | * Get loaded field instance |
| | | */ |
| | | public function getField(string $name): ?Field |
| | | { |
| | | return $this->fields[$name] ?? null; |
| | | } |
| | | |
| | | /** |
| | | * Set/add a field instance |
| | | */ |
| | | public function setField(Field $field): self |
| | | { |
| | | $this->fields[$field->name] = $field; |
| | | return $this; |
| | | } |
| | | |
| | | public function getFieldConfig(string $name): ?array |
| | | /** |
| | | * Remove a field instance |
| | | */ |
| | | public function removeField(string $name): self |
| | | { |
| | | return $this->fieldConfigs[$name] ?? null; |
| | | unset($this->fields[$name]); |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Get field configuration |
| | | */ |
| | | public function getFieldConfig(string $name): ?array |
| | | { |
| | | if (isset($this->fieldConfigs[$name])) { |
| | | return $this->fieldConfigs[$name]; |
| | | } |
| | | |
| | | // Search nested fields (repeaters, groups) |
| | | foreach ($this->fieldConfigs as $config) { |
| | | if (isset($config['fields'][$name])) { |
| | | return $config['fields'][$name]; |
| | | } |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Check if field is a WordPress default |
| | | */ |
| | | public function isWpDefault(string $name): bool |
| | | { |
| | | $defaults = self::WP_DEFAULTS[$this->objectType] ?? []; |
| | | return in_array($name, $defaults, true); |
| | | } |
| | | |
| | | /** |
| | | * Get all dirty (changed) fields |
| | | * @return array<string, Field> |
| | | */ |
| | | public function getDirtyFields(): array |
| | | { |
| | | return array_filter($this->fields, fn(Field $f) => $f->isDirty); |
| | | } |
| | | |
| | | /** |
| | | * Get all invalid fields |
| | | * @return array<string, Field> |
| | | */ |
| | | public function getInvalidFields(): array |
| | | { |
| | | return array_filter($this->fields, fn(Field $f) => !$f->isValid); |
| | | } |
| | | |
| | | /** |
| | | * Mark all loaded fields as clean |
| | | */ |
| | | public function markAllClean(): self |
| | | { |
| | | foreach ($this->fields as $field) { |
| | |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Reset all fields to original values |
| | | */ |
| | | public function resetAll(): self |
| | | { |
| | | foreach ($this->fields as $field) { |
| | | $field->reset(); |
| | | } |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Check if any fields are dirty |
| | | */ |
| | | public function hasDirtyFields(): bool |
| | | { |
| | | return !empty($this->getDirtyFields()); |
| | | } |
| | | |
| | | /** |
| | | * Check if all fields are valid |
| | | */ |
| | | public function isValid(): bool |
| | | { |
| | | return empty($this->getInvalidFields()); |
| | | } |
| | | |
| | | /** |
| | | * Get all field names from configs |
| | | */ |
| | | public function getFieldNames(): array |
| | | { |
| | | return array_keys($this->fieldConfigs); |
| | | } |
| | | |
| | | /** |
| | | * Get loaded field values as array |
| | | */ |
| | | public function toArray(): array |
| | | { |
| | | $data = []; |
| | | foreach ($this->fields as $name => $field) { |
| | | $data[$name] = $field->value; |
| | | } |
| | | return $data; |
| | | } |
| | | } |
| | |
| | | <?php |
| | | namespace JVBase\meta; |
| | | |
| | | use Exception; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | |
| | | /** |
| | | * Main facade for meta operations |
| | | * Fluent API for getting/setting meta values with validation & sanitization |
| | | * |
| | | * Usage: |
| | | * $meta = Meta::forPost($id); |
| | | * $meta->price = 150; |
| | | * $meta->save(); |
| | | * |
| | | * Meta::forPost($id)->set('price', 150)->set('style', 'traditional')->save(); |
| | | */ |
| | | class Meta |
| | | { |
| | | protected Item $item; |
| | | protected Storage $storage; |
| | | protected MetaValidator $validator; |
| | | protected MetaSanitizer $sanitizer; |
| | | protected Validator $validator; |
| | | protected Sanitizer $sanitizer; |
| | | protected MetaTypeManager $typeManager; |
| | | |
| | | protected bool $autoValidate = true; |
| | | protected bool $autoSanitize = true; |
| | | |
| | | /** @var array<string, callable[]> */ |
| | | protected array $onChangeCallbacks = []; |
| | | |
| | | /** @var array<string, callable> */ |
| | | protected array $computed = []; |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Factory Methods |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | /** |
| | | * Create Meta instance for a post |
| | | */ |
| | | public static function forPost(int $id): self |
| | | { |
| | | return new self($id, 'post'); |
| | | } |
| | | |
| | | /** |
| | | * Create Meta instance for a term |
| | | */ |
| | | public static function forTerm(int $id): self |
| | | { |
| | | return new self($id, 'term'); |
| | | } |
| | | |
| | | /** |
| | | * Create Meta instance for a user |
| | | */ |
| | | public static function forUser(int $id): self |
| | | { |
| | | return new self($id, 'user'); |
| | | } |
| | | |
| | | /** |
| | | * Create Meta instance for options |
| | | */ |
| | | public static function forOptions(?string $baseKey = null): self |
| | | { |
| | | $instance = new self($baseKey, 'options'); |
| | |
| | | return $instance; |
| | | } |
| | | |
| | | /** |
| | | * Bulk load multiple posts with optional field preloading |
| | | * @return array<int, Meta> |
| | | */ |
| | | public static function bulkForPosts(array $ids, array $preloadFields = []): array |
| | | { |
| | | return self::bulkFor($ids, 'post', $preloadFields); |
| | | } |
| | | |
| | | /** |
| | | * Bulk load multiple terms with optional field preloading |
| | | * @return array<int, Meta> |
| | | */ |
| | | public static function bulkForTerms(array $ids, array $preloadFields = []): array |
| | | { |
| | | return self::bulkFor($ids, 'term', $preloadFields); |
| | | } |
| | | |
| | | /** |
| | | * Bulk load multiple users with optional field preloading |
| | | * @return array<int, Meta> |
| | | */ |
| | | public static function bulkForUsers(array $ids, array $preloadFields = []): array |
| | | { |
| | | return self::bulkFor($ids, 'user', $preloadFields); |
| | | } |
| | | |
| | | /** |
| | | * Generic bulk loader |
| | | * @return array<int, Meta> |
| | | */ |
| | | protected static function bulkFor(array $ids, string $type, array $preloadFields = []): array |
| | | { |
| | | if (empty($ids)) { |
| | | return []; |
| | | } |
| | | |
| | | $metas = []; |
| | | |
| | | // Create instances |
| | | foreach ($ids as $id) { |
| | | $metas[$id] = new self($id, $type); |
| | | } |
| | | |
| | | // Preload fields if specified |
| | | if (!empty($preloadFields)) { |
| | | self::bulkPreload($metas, $type, $preloadFields); |
| | | } |
| | | |
| | | return $metas; |
| | | } |
| | | |
| | | /** |
| | | * Bulk preload fields for multiple Meta instances |
| | | * @param Meta[] $metas |
| | | */ |
| | | protected static function bulkPreload(array $metas, string $objectType, array $fields): void |
| | | { |
| | | if (empty($metas) || empty($fields)) { |
| | | return; |
| | | } |
| | | |
| | | $ids = array_keys($metas); |
| | | $values = Storage::getBulkValues($ids, $objectType, $fields); |
| | | |
| | | // Distribute results to Meta instances |
| | | foreach ($values as $id => $fieldValues) { |
| | | if (!isset($metas[$id])) { |
| | | continue; |
| | | } |
| | | |
| | | $meta = $metas[$id]; |
| | | foreach ($fieldValues as $name => $value) { |
| | | $config = $meta->config($name) ?? ['type' => 'text']; |
| | | $field = new Field($name, $value, $config); |
| | | $meta->item()->setField($field); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Save multiple Meta instances efficiently |
| | | * @param Meta[] $metas |
| | | * @return array<int, bool> |
| | | */ |
| | | public static function saveBulk(array $metas, bool $updateTimestamp = true): array |
| | | { |
| | | // Validate all first |
| | | $invalid = []; |
| | | foreach ($metas as $id => $meta) { |
| | | if (!$meta->isValid()) { |
| | | $invalid[$id] = $meta->getErrors(); |
| | | } |
| | | } |
| | | |
| | | if (!empty($invalid)) { |
| | | JVB()->error()->log('meta', 'Bulk save has validation errors', [ |
| | | 'invalid_items' => $invalid |
| | | ], 'warning'); |
| | | } |
| | | |
| | | // Filter to only valid metas |
| | | $validMetas = array_filter($metas, fn($m) => $m->isValid()); |
| | | |
| | | // Check overrides before bulk save |
| | | foreach ($validMetas as $meta) { |
| | | foreach ($meta->item()->getDirtyFields() as $field) { |
| | | if ($meta->checkOverrides($field)) { |
| | | $field->markClean(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | $results = Storage::saveBulk($validMetas, $updateTimestamp); |
| | | |
| | | // Mark invalid ones as failed |
| | | foreach ($invalid as $id => $errors) { |
| | | $results[$id] = false; |
| | | } |
| | | |
| | | return $results; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Constructor |
| | | // ───────────────────────────────────────────────────────────── |
| | |
| | | public function __construct(int|string|null $id, string $type) |
| | | { |
| | | $this->storage = new Storage(); |
| | | $this->validator = new MetaValidator(); |
| | | $this->sanitizer = new MetaSanitizer(); |
| | | $this->validator = new Validator(); |
| | | $this->sanitizer = new Sanitizer(); |
| | | $this->typeManager = new MetaTypeManager(); |
| | | |
| | | $this->item = $this->buildItem($id, $type); |
| | |
| | | |
| | | public function __isset(string $name): bool |
| | | { |
| | | return $this->item->hasField($name); |
| | | return $this->item->hasField($name) || isset($this->computed[$name]); |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | |
| | | */ |
| | | public function get(string $name): mixed |
| | | { |
| | | // Check computed fields first |
| | | if (isset($this->computed[$name])) { |
| | | return ($this->computed[$name])($this); |
| | | } |
| | | |
| | | // Return from loaded field if exists |
| | | if ($field = $this->item->getField($name)) { |
| | | return $field->get(); |
| | |
| | | } |
| | | |
| | | /** |
| | | * Set a field value (validates & sanitizes) |
| | | * Set a field value (validates & sanitizes by default) |
| | | */ |
| | | public function set(string $name, mixed $value): self |
| | | { |
| | |
| | | $config = ['type' => 'text', 'name' => $name]; |
| | | } |
| | | |
| | | $config['name'] = $name; |
| | | |
| | | // Validate |
| | | if ($this->autoValidate && !$this->validator->validate($value, $config)) { |
| | | $field = $this->item->getField($name) ?? new Field($name, $value, $config); |
| | |
| | | |
| | | // Get or create field |
| | | $field = $this->item->getField($name); |
| | | $oldValue = null; |
| | | |
| | | if ($field) { |
| | | $oldValue = $field->value; |
| | | $field->set($value); |
| | | } else { |
| | | // Need to load original to track dirty state |
| | | // Load original to track dirty state |
| | | $original = $this->storage->get($this->item, $name); |
| | | $oldValue = $original; |
| | | $field = new Field($name, $original, $config); |
| | | $field->set($value); |
| | | $this->item->setField($field); |
| | | } |
| | | |
| | | // Fire change callbacks |
| | | if (isset($this->onChangeCallbacks[$name]) && $oldValue !== $value) { |
| | | foreach ($this->onChangeCallbacks[$name] as $callback) { |
| | | $callback($value, $oldValue, $this); |
| | | } |
| | | } |
| | | |
| | | return $this; |
| | | } |
| | | |
| | |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * Delete multiple field values |
| | | */ |
| | | public function deleteAll(array $names): array |
| | | { |
| | | $results = []; |
| | | foreach ($names as $name) { |
| | | $results[$name] = $this->delete($name); |
| | | } |
| | | return $results; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Repeater Access |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | /** |
| | | * Get repeater accessor for fluent repeater operations |
| | | */ |
| | | public function repeater(string $name): Repeater |
| | | { |
| | | return new Repeater($this, $name); |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Utility Methods |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | /** |
| | | * Get all dirty (changed) fields |
| | | * Get all dirty (changed) field values |
| | | */ |
| | | public function getDirty(): array |
| | | { |
| | |
| | | */ |
| | | public function reset(): self |
| | | { |
| | | foreach ($this->item->fields as $field) { |
| | | $field->reset(); |
| | | } |
| | | $this->item->resetAll(); |
| | | return $this; |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * Get the underlying item (for rendering, etc) |
| | | * Re-enable validation and sanitization |
| | | */ |
| | | public function withDefaults(): self |
| | | { |
| | | $this->autoValidate = true; |
| | | $this->autoSanitize = true; |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Get the underlying Item |
| | | */ |
| | | public function item(): Item |
| | | { |
| | |
| | | return $this->item->fieldConfigs; |
| | | } |
| | | |
| | | /** |
| | | * Get item ID |
| | | */ |
| | | public function id(): int|string|null |
| | | { |
| | | return $this->item->id; |
| | | } |
| | | |
| | | /** |
| | | * Get object type (post, term, user, options) |
| | | */ |
| | | public function objectType(): string |
| | | { |
| | | return $this->item->objectType; |
| | | } |
| | | |
| | | /** |
| | | * Get content type (tattoo, artist, etc) |
| | | */ |
| | | public function contentType(): ?string |
| | | { |
| | | return $this->item->contentType; |
| | | } |
| | | |
| | | /** |
| | | * Eager load all fields |
| | | */ |
| | | public function eager(): self |
| | | { |
| | | $this->getAll(); |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Convert loaded fields to array |
| | | */ |
| | | public function toArray(): array |
| | | { |
| | | return $this->item->toArray(); |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Event Callbacks |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | /** |
| | | * Register callback for field changes |
| | | */ |
| | | public function onChange(string $field, callable $callback): self |
| | | { |
| | | $this->onChangeCallbacks[$field][] = $callback; |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Register computed/virtual field |
| | | */ |
| | | public function computed(string $name, callable $getter): self |
| | | { |
| | | $this->computed[$name] = $getter; |
| | | return $this; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Protected Helpers |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | protected function checkOverrides(Field $field): bool |
| | | /** |
| | | * Check for field update overrides |
| | | */ |
| | | public function checkOverrides(Field $field): bool |
| | | { |
| | | $name = $field->name; |
| | | $type = $field->type(); |
| | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Get default value for a field type |
| | | */ |
| | | protected function getDefaultValue(string $name): mixed |
| | | { |
| | | $config = $this->item->getFieldConfig($name); |
| | |
| | | default => '', |
| | | }; |
| | | } |
| | | |
| | | } |
| | |
| | | <?php |
| | | namespace JVBase\inc\meta; |
| | | namespace JVBase\meta; |
| | | |
| | | use JVBase\forms\TaxonomySelector; |
| | | use JVBase\forms\PostSelector; |
| | | use DateTime; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; // Exit if accessed directly |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | class MetaFormOld |
| | | { |
| | | protected int $max_file_size = 5242880; |
| | | protected int $max_file_size = 5242880; |
| | | protected ?MetaTypeManager $type_manager = null; |
| | | |
| | | //Rendering fields |
| | | public function render(string $name, mixed $value, array $config, bool $showHidden = false, bool $return = false):mixed |
| | | { |
| | | /* ========== MAIN RENDER METHOD ========== */ |
| | | public function return(string $name, mixed $value, array $config, bool $showHidden = false) |
| | | { |
| | | return $this->render($name, $value, $config, $showHidden, true); |
| | | } |
| | | public function render(string $name, mixed $value, array $config, bool $showHidden = false, bool $return = false): mixed |
| | | { |
| | | $out = ''; |
| | | if (jvbCheck('hidden', $config) && !$showHidden) { |
| | | return $out; |
| | | } |
| | | // Get conditional attributes if they exist |
| | | $conditional = array_key_exists('condition', $config) ? |
| | | $this->handleConditionalField($config) : ''; |
| | | if (jvbCheck('hidden', $config) && !$showHidden) { |
| | | return $out; |
| | | } |
| | | |
| | | if (!array_key_exists('type', $config)) { |
| | | return $out; |
| | | } |
| | | if (!array_key_exists('type', $config)) { |
| | | return $out; |
| | | } |
| | | if (!$value) { |
| | | $value = $this->getDefaultValue($config['type']); |
| | | } |
| | | |
| | | if (array_key_exists('display', $config) && $config['display'] === 'hidden'){ |
| | | $out = '<input type="hidden" name="'.$name.'" value="'.$value.'" />'; |
| | | // Handle hidden display type |
| | | if (array_key_exists('display', $config) && $config['display'] === 'hidden') { |
| | | $out = '<input type="hidden" name="' . $name . '" value="' . $value . '" />'; |
| | | if (!$return) { |
| | | echo $out; |
| | | } |
| | |
| | | } |
| | | |
| | | ob_start(); |
| | | $type = array_map( 'ucfirst', explode('_', $config['type'])); |
| | | $type = implode('', $type); |
| | | $method = 'render' . $type . 'Field'; |
| | | |
| | | // Try custom function overrides first |
| | | $type = array_map('ucfirst', explode('_', $config['type'])); |
| | | $type = implode('', $type); |
| | | $method = 'render' . $type . 'Field'; |
| | | |
| | | $nameTemp = implode('', array_map('ucfirst', explode('_', $name))); |
| | | $nameMethod = 'render'.$nameTemp.'Field'; |
| | | if(function_exists($nameMethod)) { |
| | | $nameMethod = 'render' . $nameTemp . 'Field'; |
| | | |
| | | if (function_exists($nameMethod)) { |
| | | call_user_func($nameMethod, $value, $config); |
| | | } elseif (function_exists($method)) { |
| | | call_user_func($method, $value, $config); |
| | | } elseif (method_exists($this, $method)) { |
| | | $this->$method($name, $value, $config); |
| | | } |
| | | |
| | | |
| | | $this->$method($name, $value, $config); |
| | | } |
| | | |
| | | $out = ob_get_clean(); |
| | | |
| | | do_action('jvbRenderFormField', $name, $config, $value); |
| | | $out = apply_filters('jvbFilterRenderFormField', $out, $name, $config, $value); |
| | | |
| | | if (!$return) { |
| | | echo $out; |
| | | } |
| | | return $out; |
| | | } |
| | | } |
| | | |
| | | public function renderTextField(string $name, mixed $value, array $field):void |
| | | { |
| | | // Use field-specific value if provided, otherwise use the meta value |
| | | $display_value = isset($field['value']) ? $field['value'] : $value; |
| | | $conditional = $this->handleConditionalField($field); |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'].'::'.$name; |
| | | } |
| | | $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : ''; |
| | | $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : ''; |
| | | ?> |
| | | <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>"> |
| | | <label for="<?= esc_attr($name); ?>"> |
| | | <?= esc_html($field['label']); ?> |
| | | <?php if (!empty($field['limit'])) : ?> |
| | | <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>"> |
| | | <span class="current">0</span>/<?= esc_attr($field['limit']); ?> |
| | | </span> |
| | | <?php endif; ?> |
| | | </label> |
| | | <input |
| | | type="<?= esc_attr($field['subtype']??'text'); ?>" |
| | | id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | value="<?= esc_attr($display_value); ?>" |
| | | <?= $placeholder ?> |
| | | <?= $autocomplete ?> |
| | | <?= !empty($field['required']) ? 'required' : ''; ?> |
| | | <?= $describedBy ?> |
| | | > |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | </div> |
| | | <?php |
| | | if (array_key_exists('limit', $field)) { |
| | | $this->outputCharacterCountJS(); |
| | | } |
| | | } |
| | | public function getDefaultValue(string $type):mixed { |
| | | if (!$this->type_manager) { |
| | | $this->type_manager = new MetaTypeManager(); |
| | | } |
| | | return match ($this->type_manager->getMetaType($type)) { |
| | | 'object', 'array' => [], |
| | | 'boolean' => false, |
| | | 'integer' => 0, |
| | | default => '', |
| | | }; |
| | | } |
| | | |
| | | private function renderTelField(string $name, mixed $value, array $field):void |
| | | {$describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | // Use field-specific value if provided, otherwise use the meta value |
| | | $display_value = isset($field['value']) ? $field['value'] : $value; |
| | | $conditional = $this->handleConditionalField($field); |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'].'::'.$name; |
| | | } |
| | | $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : ''; |
| | | $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : ''; |
| | | ?> |
| | | <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>"> |
| | | <label for="<?= esc_attr($name); ?>"> |
| | | <?= esc_html($field['label']); ?> |
| | | <?php if (!empty($field['limit'])) : ?> |
| | | <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>"> |
| | | <span class="current">0</span>/<?= esc_attr($field['limit']); ?> |
| | | </span> |
| | | <?php endif; ?> |
| | | </label> |
| | | <input |
| | | type="tel" |
| | | id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | value="<?= esc_attr($display_value); ?>" |
| | | <?= $placeholder ?> |
| | | <?= $autocomplete?> |
| | | <?= $describedBy ?> |
| | | <?= !empty($field['required']) ? 'required' : ''; ?> |
| | | > |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | </div> |
| | | <?php |
| | | if (array_key_exists('limit', $field)) { |
| | | $this->outputCharacterCountJS(); |
| | | } |
| | | } |
| | | private function renderEmailField(string $name, mixed $value, array $field):void |
| | | { |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | // Use field-specific value if provided, otherwise use the meta value |
| | | $display_value = isset($field['value']) ? $field['value'] : $value; |
| | | $conditional = $this->handleConditionalField($field); |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'].'::'.$name; |
| | | } |
| | | $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : ''; |
| | | $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : ''; |
| | | ?> |
| | | <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>"> |
| | | <label for="<?= esc_attr($name); ?>"> |
| | | <?= esc_html($field['label']); ?> |
| | | <?php if (!empty($field['limit'])) : ?> |
| | | <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>"> |
| | | <span class="current">0</span>/<?= esc_attr($field['limit']); ?> |
| | | </span> |
| | | <?php endif; ?> |
| | | </label> |
| | | <input |
| | | type="email" |
| | | <?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | value="<?= esc_attr($display_value); ?>" |
| | | <?= $placeholder ?> |
| | | <?= $autocomplete ?> |
| | | <?= $describedBy ?> |
| | | <?= !empty($field['required']) ? 'required' : ''; ?> |
| | | > |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | </div> |
| | | <?php |
| | | if (array_key_exists('limit', $field)) { |
| | | $this->outputCharacterCountJS(); |
| | | } |
| | | } |
| | | /* ========== HELPER METHODS ========== */ |
| | | |
| | | private function renderUrlField(string $name, mixed $value, array $field):void |
| | | { |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | // Use field-specific value if provided, otherwise use the meta value |
| | | $display_value = isset($field['value']) ? $field['value'] : $value; |
| | | $conditional = $this->handleConditionalField($field); |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'].'::'.$name; |
| | | } |
| | | $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : ''; |
| | | $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : ''; |
| | | ?> |
| | | <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>"> |
| | | <label for="<?= esc_attr($name); ?>"> |
| | | <?= esc_html($field['label']); ?> |
| | | <?php if (!empty($field['limit'])) : ?> |
| | | <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>"> |
| | | <span class="current">0</span>/<?= esc_attr($field['limit']); ?> |
| | | </span> |
| | | <?php endif; ?> |
| | | </label> |
| | | <input |
| | | type="url" |
| | | id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | value="<?= esc_attr($display_value); ?>" |
| | | <?= $placeholder ?> |
| | | <?= $describedBy ?> |
| | | <?= $autocomplete ?> |
| | | <?= !empty($field['required']) ? 'required' : ''; ?> |
| | | > |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | </div> |
| | | <?php |
| | | if (array_key_exists('limit', $field)) { |
| | | $this->outputCharacterCountJS(); |
| | | } |
| | | } |
| | | /** |
| | | * Prepare common field data |
| | | */ |
| | | protected function prepareFieldData(string $name, mixed $value, array $field): array |
| | | { |
| | | return [ |
| | | 'name' => array_key_exists('group', $field) ? $field['group'] . '::' . $name : $name, |
| | | 'value' => isset($field['value']) ? $field['value'] : $value, |
| | | 'id' => (array_key_exists('base', $field) ? esc_attr($field['base']) : '') . esc_attr($name), |
| | | ]; |
| | | } |
| | | |
| | | private function renderNumberField(string $name, mixed $value, array $field):void |
| | | { |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | $description = '<ul class="list-none"><li>Tip: hold Ctrl/Command to increase 5x</li><li>Shift to increase 10x,</li><li>Or Ctrl/Command + Shift to increase 50x</li></ul>'; |
| | | $description .= $field['description']??''; |
| | | $conditional = $this->handleConditionalField($field); |
| | | /** |
| | | * Build common HTML attributes for inputs |
| | | */ |
| | | protected function buildInputAttributes(string $name, array $field): string |
| | | { |
| | | $attrs = []; |
| | | |
| | | // Conditional rendering |
| | | if (array_key_exists('condition', $field)) { |
| | | $attrs['conditional'] = $this->handleConditionalField($field); |
| | | } |
| | | |
| | | // Accessibility |
| | | if (!empty($field['description'])) { |
| | | $attrs['aria-describedby'] = $name . '-help'; |
| | | } |
| | | |
| | | // Common attributes |
| | | $common = ['placeholder', 'autocomplete', 'pattern', 'minlength', 'maxlength', 'min', 'max', 'step']; |
| | | foreach ($common as $attr) { |
| | | if (array_key_exists($attr, $field)) { |
| | | $attrs[$attr] = $field[$attr]; |
| | | } |
| | | } |
| | | |
| | | // Required |
| | | if (!empty($field['required'])) { |
| | | $attrs['required'] = true; |
| | | } |
| | | |
| | | // Build attribute string |
| | | $attrString = ''; |
| | | foreach ($attrs as $key => $val) { |
| | | if ($key === 'conditional') { |
| | | $attrString .= ' ' . $val; // Already formatted |
| | | } elseif ($val === true) { |
| | | $attrString .= ' ' . $key; |
| | | } else { |
| | | $attrString .= ' ' . $key . '="' . esc_attr($val) . '"'; |
| | | } |
| | | } |
| | | |
| | | return $attrString; |
| | | } |
| | | |
| | | /** |
| | | * Build validation data attributes |
| | | */ |
| | | protected function buildValidationAttributes(array $field): string |
| | | { |
| | | $attrs = []; |
| | | |
| | | if (!empty($field['pattern'])) { |
| | | $attrs['data-pattern'] = $field['pattern']; |
| | | } |
| | | |
| | | if (!empty($field['validate'])) { |
| | | $attrs['data-validate'] = $field['validate']; |
| | | } |
| | | |
| | | if (isset($field['min'])) { |
| | | $attrs['data-min'] = $field['min']; |
| | | } |
| | | |
| | | if (isset($field['max'])) { |
| | | $attrs['data-max'] = $field['max']; |
| | | } |
| | | |
| | | if (isset($field['minlength'])) { |
| | | $attrs['data-minlength'] = $field['minlength']; |
| | | } |
| | | |
| | | if (isset($field['maxlength'])) { |
| | | $attrs['data-maxlength'] = $field['maxlength']; |
| | | } |
| | | |
| | | if (!empty($field['validation_message'])) { |
| | | $attrs['data-validation-message'] = $field['validation_message']; |
| | | } |
| | | |
| | | $attrs['data-type'] = $field['type']; |
| | | |
| | | $attrString = ''; |
| | | foreach ($attrs as $key => $val) { |
| | | $attrString .= ' ' . $key . '="' . esc_attr($val) . '"'; |
| | | } |
| | | |
| | | return $attrString; |
| | | } |
| | | |
| | | /* ========== GENERIC FIELD WRAPPER ========== */ |
| | | |
| | | /** |
| | | * Render a standard input field with validation wrapper |
| | | */ |
| | | protected function renderStandardInput(string $name, mixed $value, array $field, string $inputType = 'text'): void |
| | | { |
| | | $data = $this->prepareFieldData($name, $value, $field); |
| | | $inputAttrs = $this->buildInputAttributes($name, $field); |
| | | $validationAttrs = $this->buildValidationAttributes($field); |
| | | $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : ''; |
| | | |
| | | $pattern = array_key_exists('pattern', $field) ? $field['pattern'] : ''; |
| | | $customData = ''; |
| | | if (array_key_exists('data', $field) && !empty($field['data'])) { |
| | | foreach ($field['data'] as $key => $v) { |
| | | $customData .= ($v === '') ? ' data-' . $key : ' data-' . $key . '="' . $v . '"'; |
| | | } |
| | | } |
| | | ?> |
| | | <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>" |
| | | <?= $conditional ?> |
| | | data-field="<?= esc_attr($name) ?>" |
| | | data-field-type="<?=esc_attr($field['type'])?>" |
| | | <?= $validationAttrs ?>> |
| | | |
| | | <?php $this->renderLabel($name, $field); ?> |
| | | |
| | | <div class="field-input-wrapper"> |
| | | <input |
| | | type="<?= esc_attr($inputType) ?>" |
| | | id="<?= esc_attr($data['id']) ?>" |
| | | name="<?= esc_attr($data['name']) ?>" |
| | | value="<?= esc_attr($data['value']) ?>" |
| | | <?= $inputAttrs ?> |
| | | <?= $customData?> |
| | | <?= $pattern?> |
| | | > |
| | | <span class="validation-icon success" hidden aria-hidden="true"> |
| | | <?= jvbIcon('check-circle') ?> |
| | | </span> |
| | | <span class="validation-icon error" hidden aria-hidden="true"> |
| | | <?= jvbIcon('x-circle') ?> |
| | | </span> |
| | | </div> |
| | | |
| | | <span class="validation-message" hidden role="alert"></span> |
| | | |
| | | <?php $this->renderHintAndDescription($field, $name); ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | /** |
| | | * Render field label with optional character count |
| | | */ |
| | | protected function renderLabel(string $name, array $field): void |
| | | { |
| | | ?> |
| | | <label for="<?= esc_attr($name) ?>"> |
| | | <?= esc_html($field['label']) ?> |
| | | <?php if (!empty($field['required'])) : ?> |
| | | <span class="required" aria-label="required">*</span> |
| | | <?php endif; ?> |
| | | <?php if (!empty($field['limit'])) : ?> |
| | | <span class="char-count" data-limit="<?= esc_attr($field['limit']) ?>"> |
| | | <span class="current">0</span>/<?= esc_attr($field['limit']) ?> |
| | | </span> |
| | | <?php endif; ?> |
| | | </label> |
| | | <?php |
| | | } |
| | | |
| | | /** |
| | | * Render hint and description |
| | | */ |
| | | protected function renderHintAndDescription(array $field, string $name): void |
| | | { |
| | | if (!empty($field['hint'])) { |
| | | $this->renderHint($field['hint']); |
| | | } |
| | | |
| | | if (!empty($field['description'])) { |
| | | $this->renderDescription($field['description'], $name); |
| | | } |
| | | } |
| | | |
| | | protected function renderHint(string $hint): void |
| | | { |
| | | ?> |
| | | <span class="hint"><?= esc_html($hint) ?></span> |
| | | <?php |
| | | } |
| | | |
| | | protected function renderDescription(string $description, string $name): void |
| | | { |
| | | ?> |
| | | <p class="description" id="<?= esc_attr($name) ?>-help"> |
| | | <?= wp_kses_post($description) ?> |
| | | </p> |
| | | <?php |
| | | } |
| | | |
| | | /* ========== SIMPLE INPUT FIELD TYPES ========== */ |
| | | |
| | | public function renderTextField(string $name, mixed $value, array $field): void |
| | | { |
| | | $this->renderStandardInput($name, $value, $field, $field['subtype'] ?? 'text'); |
| | | } |
| | | |
| | | public function renderEmailField(string $name, mixed $value, array $field): void |
| | | { |
| | | $field['validate'] = 'email'; // Auto-add email validation |
| | | $this->renderStandardInput($name, $value, $field, 'email'); |
| | | } |
| | | |
| | | private function renderUrlField(string $name, mixed $value, array $field): void |
| | | { |
| | | $field['validate'] = 'url'; // Auto-add URL validation |
| | | $this->renderStandardInput($name, $value, $field, 'url'); |
| | | } |
| | | |
| | | private function renderTelField(string $name, mixed $value, array $field): void |
| | | { |
| | | $field['validate'] = 'phone'; // Auto-add phone validation |
| | | $this->renderStandardInput($name, $value, $field, 'tel'); |
| | | } |
| | | |
| | | private function renderDateField(string $name, mixed $value, array $field): void |
| | | { |
| | | $format = !empty($field['format']) ? $field['format'] : 'Y-m-d'; |
| | | |
| | | // Format the date if we have a value |
| | | if (!empty($value)) { |
| | | $date = DateTime::createFromFormat($format, $value); |
| | | if ($date) { |
| | | $value = $date->format('Y-m-d'); // HTML date input requires Y-m-d format |
| | | } |
| | | } |
| | | |
| | | $this->renderStandardInput($name, $value, $field, 'date'); |
| | | } |
| | | |
| | | private function renderTimeField(string $name, mixed $value, array $field): void |
| | | { |
| | | $this->renderStandardInput($name, $value, $field, 'time'); |
| | | } |
| | | |
| | | private function renderDatetimeField(string $name, mixed $value, array $field): void |
| | | { |
| | | $this->renderStandardInput($name, $value, $field, 'datetime-local'); |
| | | } |
| | | |
| | | /* ========== TEXTAREA FIELD ========== */ |
| | | |
| | | public function renderTextareaField(string $name, mixed $value, array $field): void |
| | | { |
| | | $data = $this->prepareFieldData($name, $value, $field); |
| | | $inputAttrs = $this->buildInputAttributes($name, $field); |
| | | $validationAttrs = $this->buildValidationAttributes($field); |
| | | $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : ''; |
| | | |
| | | $rows = isset($field['rows']) ? (int)$field['rows'] : 4; |
| | | $quill = (array_key_exists('quill', $field) && $field['quill'] == true) ? ' data-editor="true"' : ''; |
| | | |
| | | if ($quill !== '') { |
| | | $allowImages = array_key_exists('allowImage', $field); |
| | | $quill .= ($allowImages) ? ' data-allowimage="true"' : ' data-allowimage="false"'; |
| | | } |
| | | |
| | | // Handle array values |
| | | if (is_array($value)) { |
| | | $value = implode(', ', $value); |
| | | } |
| | | |
| | | ?> |
| | | <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>" |
| | | <?= $conditional ?> |
| | | data-field="<?= esc_attr($name) ?>" |
| | | data-field-type="<?=esc_attr($field['type'])?>" |
| | | <?= $validationAttrs ?>> |
| | | |
| | | <?php $this->renderLabel($name, $field); ?> |
| | | |
| | | <div class="field-input-wrapper"> |
| | | <textarea |
| | | id="<?= esc_attr($data['id']) ?>" |
| | | name="<?= esc_attr($data['name']) ?>" |
| | | rows="<?= esc_attr($rows) ?>" |
| | | <?= $quill ?> |
| | | <?= $inputAttrs ?> |
| | | ><?= esc_textarea($data['value']) ?></textarea> |
| | | <span class="validation-icon success" hidden aria-hidden="true"> |
| | | <?= jvbIcon('check-circle') ?> |
| | | </span> |
| | | <span class="validation-icon error" hidden aria-hidden="true"> |
| | | <?= jvbIcon('x-circle') ?> |
| | | </span> |
| | | </div> |
| | | |
| | | <span class="validation-message" hidden role="alert"></span> |
| | | |
| | | <?php $this->renderHintAndDescription($field, $name); ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | /* ========== NUMBER FIELD ========== */ |
| | | |
| | | private function renderNumberField(string $name, mixed $value, array $field): void |
| | | { |
| | | $data = $this->prepareFieldData($name, $value, $field); |
| | | $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : ''; |
| | | $validationAttrs = $this->buildValidationAttributes($field); |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : ''; |
| | | |
| | | $min = isset($field['min']) ? (float)$field['min'] : 0; |
| | | $max = isset($field['max']) ? (float)$field['max'] : 100; |
| | | $step = isset($field['step']) ? (float)$field['step'] : 1; |
| | | |
| | | $data = ''; |
| | | // Handle custom data attributes |
| | | $customData = ''; |
| | | if (array_key_exists('data', $field) && !empty($field['data'])) { |
| | | foreach($field['data'] as $key => $v) { |
| | | if ($v === '') { |
| | | $data .= ' data-'.$key; |
| | | } else { |
| | | $data .= ' data-'.$key.'="'.$v.'"'; |
| | | } |
| | | foreach ($field['data'] as $key => $v) { |
| | | $customData .= ($v === '') ? ' data-' . $key : ' data-' . $key . '="' . $v . '"'; |
| | | } |
| | | } |
| | | |
| | | if (empty($value)) { |
| | | $value = $field['default']??0; |
| | | $value = $field['default'] ?? 0; |
| | | } |
| | | |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'].'::'.$name; |
| | | } |
| | | $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="'.$field['autocomplete'].'"' : ''; |
| | | ?> |
| | | <div class="field <?=$field['type']?> <?=$name?> row" <?=$conditional?> data-field="<?=$name?>"> |
| | | <label for="<?= esc_attr($name); ?>"> |
| | | <?= esc_html($field['label']); ?> |
| | | </label> |
| | | $autocomplete = (array_key_exists('autocomplete', $field)) ? ' autocomplete="' . $field['autocomplete'] . '"' : ''; |
| | | |
| | | <div class="quantity" |
| | | <?=$data?>> |
| | | |
| | | <button type="button" |
| | | class="decrease" |
| | | title="<?= array_key_exists('remove', $field) ? $field['remove'] : 'Decrease amount' ?>" |
| | | aria-label="Decrease <?= esc_attr($field['label']); ?>"> |
| | | <?= jvbIcon('minus-square')?> |
| | | </button> |
| | | |
| | | <input type="number" |
| | | id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | value="<?= esc_attr($value); ?>" |
| | | min="<?= esc_attr($min); ?>" |
| | | max="<?= esc_attr($max); ?>" |
| | | step="<?= esc_attr($step); ?>" |
| | | class="quantity-input" |
| | | <?= $describedBy ?> |
| | | <?= $autocomplete ?> |
| | | <?= !empty($field['required']) ? 'required' : ''; ?>> |
| | | |
| | | <button type="button" |
| | | class="increase" |
| | | title="<?= array_key_exists('add', $field) ? $field['add'] : 'Increase amount' ?>" |
| | | aria-label="Increase <?= esc_attr($field['label']); ?>"> |
| | | <?= jvbIcon('plus-square')?> |
| | | </button> |
| | | </div> |
| | | <?php $this->renderDescription($description, $name); ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | public function renderTextareaField(string $name, mixed $value, array $field):void |
| | | { |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | $rows = isset($field['rows']) ? (int)$field['rows'] : 4; |
| | | $conditional = $this->handleConditionalField($field); |
| | | $quill = (array_key_exists('quill', $field) && $field['quill'] == true) ? ' data-editor="true"' : ''; |
| | | if ($quill !== '') { |
| | | $allowImages = array_key_exists('allowImage', $field); |
| | | $quill .= ($allowImages) ? ' data-allowimage="true"' : ' data-allowimage="false"'; |
| | | } |
| | | // Handle array values |
| | | if (is_array($value)) { |
| | | $value = implode(', ', $value); |
| | | } |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'].'::'.$name; |
| | | } |
| | | $placeholder = (array_key_exists('placeholder', $field)) ? ' placeholder="'.$field['placeholder'].'"' : ''; |
| | | ?> |
| | | <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>"> |
| | | <label for="<?= esc_attr($name ?? ''); ?>"> |
| | | <?= esc_html($field['label']); ?> |
| | | <?php if (!empty($field['limit'])) : ?> |
| | | <span class="char-count" data-limit="<?= esc_attr($field['limit']); ?>"> |
| | | <span class="current">0</span>/<?= esc_attr($field['limit']); ?> |
| | | </span> |
| | | <?php endif; ?> |
| | | </label> |
| | | <textarea |
| | | id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name ?? ''); ?>" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name ?? ''); ?>" |
| | | <?= $quill ?> |
| | | rows="<?= esc_attr($rows); ?>" |
| | | <?= $placeholder ?> |
| | | <?= $describedBy ?> |
| | | <?= !empty($field['required']) ? 'required' : ''; ?> |
| | | <?= !empty($field['limit']) ? 'data-limit="' . esc_attr($field['limit']) . '"' : ''; ?> |
| | | ><?= esc_textarea($value); ?></textarea> |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | private function renderSetField(string $name, mixed $value, array $field):void |
| | | { |
| | | $this->renderCheckboxField($name, $value, $field); |
| | | } |
| | | |
| | | private function renderCheckboxField(string $name, mixed $value, array $field):void |
| | | { |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | $value = !is_array($value) ? explode(',', $value) : $value; |
| | | $limit = isset($field['limit']) ? (int)$field['limit'] : 0; |
| | | $conditional = $this->handleConditionalField($field); |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'].'::'.$name; |
| | | } |
| | | ?> |
| | | <div class="field checkbox-group" <?= $limit ? 'data-limit="' . esc_attr($limit) . '"' : ''; ?> <?=$conditional?> data-field="<?=$name?>"<?=$describedBy?>> |
| | | <span class="label"><?= esc_html($field['label']); ?></span> |
| | | <div class="checkbox-options flex"> |
| | | <?php foreach ($field['options'] as $key => $label) : ?> |
| | | <input |
| | | type="checkbox" |
| | | id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name).'-'.esc_attr($key)?>" |
| | | <?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name)?>[]" |
| | | value="<?= esc_attr($key); ?>" |
| | | <?= (in_array($key, $value)) ? 'checked' : ''; ?> |
| | | <?= !empty($field['required']) ? 'required' : ''; ?> |
| | | > |
| | | <label class="checkbox-option" for="<?= esc_attr($name).'-'.esc_attr($key) ?>"> |
| | | <?= esc_html($label); ?> |
| | | </label> |
| | | <?php endforeach; ?> |
| | | </div> |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | private function renderRadioField(string $name, mixed $value, array $field):void |
| | | { |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | $value = (array)$value; |
| | | $conditional = $this->handleConditionalField($field); |
| | | |
| | | if (!array_key_exists('label', $field)) { |
| | | error_log('No label for: '.print_r($name, true)); |
| | | } |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'].'::'.$name; |
| | | } |
| | | ?> |
| | | <div class="field radio-group"<?=$conditional?> data-field="<?=$name?>"<?=$describedBy?>> |
| | | <label><?= esc_html($field['label']); ?></label> |
| | | |
| | | <div class="radio-options row"> |
| | | <?php foreach ($field['options'] as $key => $label) : ?> |
| | | <input |
| | | type="radio" |
| | | id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name).'-'.esc_attr($key) ?>" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | value="<?= esc_attr($key); ?>" |
| | | <?= !empty($field['required']) ? 'required' : ''; ?> |
| | | <?= (in_array($key, $value)) ? 'checked' : ''; ?> |
| | | > |
| | | <label class="radio-option" for="<?= esc_attr($name).'-'.esc_attr($key) ?>"> |
| | | <?= esc_html($label); ?> |
| | | </label> |
| | | <?php endforeach; ?> |
| | | </div> |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | private function renderRepeaterField(string $name, mixed $value, array $field):void |
| | | { |
| | | error_log('Rendering Repeater Field!'); |
| | | $values = is_array($value) ? $value : array(); |
| | | |
| | | $conditional = $this->handleConditionalField($field); |
| | | $row_label = isset($field['row_label']) ? $field['row_label'] : ''; |
| | | $rowTitle = (array_key_exists('new_row', $field)) ? $field['new_row'] : 'New Item'; |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'].'::'.$name; |
| | | } |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | ?> |
| | | <div class="field repeater <?=$name?>" |
| | | data-field="<?= esc_attr($name); ?>" |
| | | <?= $describedBy ?> |
| | | <?= $row_label ? 'data-label="' . esc_attr($row_label) . '"' : ''; ?> |
| | | <?=$conditional?>> |
| | | <?php |
| | | if (!array_key_exists('label', $field)) { |
| | | error_log('No label for: '.print_r($name, true)); |
| | | } |
| | | ?> |
| | | <h3><?= esc_html($field['label']); ?></h3> |
| | | |
| | | |
| | | <div class="repeater-items"> |
| | | <?php |
| | | if (!empty($values)) { |
| | | foreach ($values as $index => $row) { |
| | | $this->renderRepeaterRow($field['fields'], $row, $index, $name, $rowTitle); |
| | | } |
| | | } |
| | | ?> |
| | | </div> |
| | | |
| | | <template class="<?=uniqid('repeaterTemplate')?>"> |
| | | <?php $this->renderRepeaterRow($field['fields'], array(), '', '', $rowTitle); ?> |
| | | </template> |
| | | |
| | | <button type="button" class="add-repeater-row"> |
| | | <?= jvbIcon('plus', ['title'=> 'Add']); ?> <?= (array_key_exists('add_label', $field)) ? $field['add_label'] : 'Add Item'; ?> |
| | | </button> |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | private function renderRepeaterRow(array $fields, array $values, int|string $index, string $base_name, string $rowTitle = 'New Item'):void |
| | | { |
| | | $display_number = (is_string($index)) ? $index : ($index + 1); |
| | | ?> |
| | | <div class="repeater-row" data-index="<?= esc_attr($index); ?>"> |
| | | <details <?= (is_string($index)) ? 'open' : ''; ?>> |
| | | <summary class="repeater-row-header row btw"> |
| | | <span class="drag-handle"><?= jvbIcon('dots-six-vertical'); ?></span> |
| | | <span class="row-number">#<?= esc_html($display_number); ?></span> |
| | | <span class="row-title"><?= esc_html($this->getRowTitle($fields, $values, $rowTitle)); ?></span> |
| | | <button type="button" class="remove-row" title="Remove"> |
| | | <?= jvbIcon('trash', ['title'=>'Remove']); ?> |
| | | </button> |
| | | </summary> |
| | | <div class="repeater-row-content"> |
| | | <?php |
| | | foreach ($fields as $slug => $field) : |
| | | if ($base_name === '') { |
| | | $field_name = $slug; |
| | | } else { |
| | | $field_name = sprintf('%s:%s:%s', $base_name, $index, $slug); |
| | | } |
| | | $field_value = isset($values[$slug]) ? $values[$slug] : ''; |
| | | $name = $field_name; |
| | | $this->render($name, $field_value, $field); |
| | | endforeach; |
| | | ?> |
| | | </div> |
| | | </details> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | private function getRowTitle(array $fields, array $values, string $rowTitle):string |
| | | { |
| | | // Try to find the first text field or textarea value to use as title |
| | | foreach ($fields as $slug => $field) { |
| | | if (in_array($field['type'], ['text', 'textarea']) && |
| | | isset($values[$slug]) && |
| | | !empty($values[$slug])) { |
| | | return $values[$slug]; |
| | | } |
| | | } |
| | | return $rowTitle; |
| | | } |
| | | |
| | | private function renderTaxonomyField(string $name, mixed $value, array $field):void |
| | | { |
| | | $conditional = $this->handleConditionalField($field); |
| | | $taxonomy = $field['taxonomy']; |
| | | |
| | | // Get currently selected terms |
| | | $selected_terms = ($value === '') ? [] : explode(',', $value); |
| | | |
| | | |
| | | // Convert selected term IDs to the format expected by single modal |
| | | $processedSelected = []; |
| | | if (!empty($selected_terms)) { |
| | | foreach ($selected_terms as $termId) { |
| | | if (is_numeric($termId)) { |
| | | $term = get_term($termId, $taxonomy); |
| | | if ($term && !is_wp_error($term)) { |
| | | $processedSelected[$term->term_id] = [ |
| | | 'name' => html_entity_decode($term->name), |
| | | 'path' => TaxonomySelector::getTermPath($term) |
| | | ]; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Create configuration for single modal system |
| | | $config = [ |
| | | 'taxonomy' => $taxonomy, |
| | | 'max' => $field['limit'] ?? 0, |
| | | 'search' => $field['search'] ?? true, |
| | | 'createNew' => $field['createNew'] ?? false, |
| | | 'selected' => $processedSelected, |
| | | 'base' => $field['base'] ?? '', |
| | | ]; |
| | | |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | ?> |
| | | <div class="field taxonomy <?=$name?>" <?= $conditional ?> data-field="<?=$name?>"> |
| | | <div class="field-group-header"> |
| | | <label class="toggle"> |
| | | <?= jvbIcon(str_replace(BASE, '', $taxonomy)) ?> |
| | | <?= esc_html($field['label']) ?> |
| | | </label> |
| | | </div> |
| | | <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?> row" |
| | | <?= $conditional ?> |
| | | data-field="<?= esc_attr($name) ?>" |
| | | data-field-type="<?=esc_attr($field['type'])?>" |
| | | <?= $validationAttrs ?>> |
| | | |
| | | <?php |
| | | $tax = new TaxonomySelector($name, $taxonomy, $config); |
| | | $extra = '<input type="hidden" |
| | | name="'.esc_attr($name).'" |
| | | id="'.esc_attr($name).'"'.$describedBy.' |
| | | data-taxonomy="'.esc_attr($taxonomy).'" |
| | | value="'.esc_attr(is_array($selected_terms) ? implode(',', $selected_terms) : $selected_terms).'">'; |
| | | echo $tax->render([], $extra); |
| | | ?> |
| | | <?php $this->renderLabel($name, $field); ?> |
| | | |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | <div class="quantity" <?= $customData ?>> |
| | | <button type="button" |
| | | class="decrease" |
| | | title="<?= array_key_exists('remove', $field) ? $field['remove'] : 'Decrease amount' ?>" |
| | | aria-label="Decrease <?= esc_attr($field['label']) ?>"> |
| | | <?= jvbIcon('minus-square') ?> |
| | | </button> |
| | | |
| | | protected function renderPostSelectorField(string $name, mixed $value, array $field):void |
| | | { |
| | | $conditional = $this->handleConditionalField($field); |
| | | <input type="number" |
| | | id="<?= esc_attr($data['id']) ?>" |
| | | name="<?= esc_attr($data['name']) ?>" |
| | | value="<?= esc_attr($value) ?>" |
| | | min="<?= esc_attr($min) ?>" |
| | | max="<?= esc_attr($max) ?>" |
| | | step="<?= esc_attr($step) ?>" |
| | | class="quantity-input" |
| | | <?= $describedBy ?> |
| | | <?= $autocomplete ?> |
| | | <?= !empty($field['required']) ? 'required' : '' ?>> |
| | | |
| | | // Process selected posts |
| | | $selected_posts = $value; |
| | | if (is_string($selected_posts)) { |
| | | $selected_posts = !empty($selected_posts) ? explode(',', $selected_posts) : []; |
| | | } elseif (!is_array($selected_posts)) { |
| | | $selected_posts = []; |
| | | } |
| | | |
| | | // Configure the post selector |
| | | $config = [ |
| | | 'multiple' => $field['multiple'] ?? true, |
| | | 'maxSelections' => $field['limit'] ?? 0, |
| | | 'search' => true, |
| | | 'placeholder' => $field['placeholder'] ?? 'Search posts...', |
| | | 'noResults' => 'No posts found', |
| | | 'shop_id' => $field['shop_id'] ?? null, |
| | | 'onClose' => 'updateMetaFormPost' |
| | | ]; |
| | | |
| | | $postSelector = new PostSelector($field['post_type'], $config); |
| | | $containerId = $name . '-post-selector'; |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | ?> |
| | | <div class="field post-selector <?=$name?>" <?= $conditional ?> data-field="<?=$name?>"> |
| | | <div class="field-group-header"> |
| | | <label class="toggle"> |
| | | <?= jvbIcon($field['post_type'] . '-selector') ?> |
| | | <?= esc_html($field['label'] ?? ucfirst($field['post_type'])) ?> |
| | | </label> |
| | | <button title="Add <?= esc_attr(ucfirst($field['post_type'])) ?>" |
| | | class="add-item-btn" |
| | | type="button"> |
| | | <?= jvbIcon('plus-square', ['title' => "Add " . ucfirst($field['post_type'])]) ?> |
| | | <button type="button" |
| | | class="increase" |
| | | title="<?= array_key_exists('add', $field) ? $field['add'] : 'Increase amount' ?>" |
| | | aria-label="Increase <?= esc_attr($field['label']) ?>"> |
| | | <?= jvbIcon('plus-square') ?> |
| | | </button> |
| | | </div> |
| | | |
| | | <?= $postSelector->render($selected_posts, $containerId) ?> |
| | | |
| | | <!-- Hidden input for form submission --> |
| | | <input type="hidden" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name) ?>" |
| | | class="post-selector-input" |
| | | <?= $describedBy ?> |
| | | data-post-type="<?= esc_attr($field['post_type']) ?>" |
| | | value="<?= esc_attr(is_array($selected_posts) ? implode(',', $selected_posts) : $selected_posts) ?>"> |
| | | |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | <?php $this->renderHintAndDescription($field, $name); ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | } |
| | | |
| | | protected function renderGroupField(string $name, mixed $value, array $field):void |
| | | /* ========== SELECT, RADIO, CHECKBOX FIELDS ========== */ |
| | | |
| | | private function renderSelectField(string $name, mixed $value, array $field): void |
| | | { |
| | | if (!array_key_exists('fields', $field) || empty($field['fields'])) { |
| | | return; |
| | | $data = $this->prepareFieldData($name, $value, $field); |
| | | $inputAttrs = $this->buildInputAttributes($name, $field); |
| | | $validationAttrs = $this->buildValidationAttributes($field); |
| | | $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : ''; |
| | | |
| | | ?> |
| | | <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>" |
| | | <?= $conditional ?> |
| | | data-field="<?= esc_attr($name) ?>" |
| | | data-field-type="<?=esc_attr($field['type'])?>" |
| | | <?= $validationAttrs ?>> |
| | | |
| | | <?php $this->renderLabel($name, $field); ?> |
| | | |
| | | <div class="field-input-wrapper"> |
| | | <select |
| | | id="<?= esc_attr($data['id']) ?>" |
| | | name="<?= esc_attr($data['name']) ?>" |
| | | <?= $inputAttrs ?>> |
| | | <?php foreach ($field['options'] as $key => $label) : ?> |
| | | <option value="<?= esc_attr($key) ?>" <?php selected($value, $key); ?>> |
| | | <?= esc_html($label) ?> |
| | | </option> |
| | | <?php endforeach; ?> |
| | | </select> |
| | | <span class="validation-icon success" hidden aria-hidden="true"> |
| | | <?= jvbIcon('check-circle') ?> |
| | | </span> |
| | | <span class="validation-icon error" hidden aria-hidden="true"> |
| | | <?= jvbIcon('x-circle') ?> |
| | | </span> |
| | | </div> |
| | | |
| | | <span class="validation-message" hidden role="alert"></span> |
| | | |
| | | <?php $this->renderHintAndDescription($field, $name); ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | private function renderRadioField(string $name, mixed $value, array $field): void |
| | | { |
| | | $data = $this->prepareFieldData($name, $value, $field); |
| | | $validationAttrs = $this->buildValidationAttributes($field); |
| | | $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : ''; |
| | | |
| | | ?> |
| | | <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>" |
| | | <?= $conditional ?> |
| | | data-field="<?= esc_attr($name) ?>" |
| | | data-field-type="<?=esc_attr($field['type'])?>" |
| | | <?= $validationAttrs ?>> |
| | | |
| | | <fieldset> |
| | | <legend><?= esc_html($field['label']) ?> |
| | | <?php if (!empty($field['required'])) : ?> |
| | | <span class="required" aria-label="required">*</span> |
| | | <?php endif; ?> |
| | | </legend> |
| | | |
| | | <?php foreach ($field['options'] as $key => $label) : ?> |
| | | <input |
| | | type="radio" |
| | | id="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>" |
| | | name="<?= esc_attr($data['name']) ?>" |
| | | value="<?= esc_attr($key) ?>" |
| | | <?php checked($value, $key); ?> |
| | | <?= !empty($field['required']) ? 'required' : '' ?> |
| | | > |
| | | <label class="radio-option" for="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>"> |
| | | <span><?= $label ?></span> |
| | | </label> |
| | | <?php endforeach; ?> |
| | | </fieldset> |
| | | |
| | | <span class="validation-message" hidden role="alert"></span> |
| | | |
| | | <?php $this->renderHintAndDescription($field, $name); ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | private function renderCheckboxField(string $name, mixed $value, array $field): void |
| | | { |
| | | $data = $this->prepareFieldData($name, $value, $field); |
| | | $validationAttrs = $this->buildValidationAttributes($field); |
| | | $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : ''; |
| | | |
| | | if (!is_array($value)) { |
| | | $value = !empty($value) ? [$value] : []; |
| | | } |
| | | |
| | | // Handle conditional fields |
| | | $conditional = $this->handleConditionalField($field); |
| | | ?> |
| | | <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>" |
| | | <?= $conditional ?> |
| | | data-field="<?= esc_attr($name) ?>" |
| | | data-field-type="<?=esc_attr($field['type'])?>" |
| | | <?= $validationAttrs ?>> |
| | | |
| | | // Ensure value is an array |
| | | $values = is_array($value) ? $value : []; |
| | | $original = $name; |
| | | <fieldset> |
| | | <legend><?= esc_html($field['label']) ?> |
| | | <?php if (!empty($field['required'])) : ?> |
| | | <span class="required" aria-label="required">*</span> |
| | | <?php endif; ?> |
| | | </legend> |
| | | |
| | | <?php foreach ($field['options'] as $key => $label) : ?> |
| | | <input |
| | | type="checkbox" |
| | | id="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>" |
| | | name="<?= esc_attr($data['name']) ?>[]" |
| | | value="<?= esc_attr($key) ?>" |
| | | <?php checked(in_array($key, $value)); ?> |
| | | > |
| | | <label class="checkbox-option" for="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>"> |
| | | <span><?= esc_html($label) ?></span> |
| | | </label> |
| | | <?php endforeach; ?> |
| | | </fieldset> |
| | | |
| | | <span class="validation-message" hidden role="alert"></span> |
| | | |
| | | <?php $this->renderHintAndDescription($field, $name); ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | private function renderTrueFalseField(string $name, mixed $value, array $field): void |
| | | { |
| | | $data = $this->prepareFieldData($name, $value, $field); |
| | | $validationAttrs = $this->buildValidationAttributes($field); |
| | | $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : ''; |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : ''; |
| | | |
| | | ?> |
| | | <div class="field true-false <?= esc_attr($name) ?> row btw" |
| | | <?= $conditional ?> |
| | | data-field="<?= esc_attr($name) ?>" |
| | | data-field-type="<?=esc_attr($field['type'])?>" |
| | | <?= $validationAttrs ?>> |
| | | |
| | | <label class="toggle-switch row" <?= $describedBy ?>> |
| | | <input |
| | | type="checkbox" |
| | | name="<?= esc_attr($data['name']) ?>" |
| | | value="1" |
| | | <?= ($value) ? ' checked' : '' ?> |
| | | <?= !empty($field['required']) ? 'required' : '' ?> |
| | | > |
| | | <div class="slider"></div> |
| | | <span class="toggle-label"> |
| | | <?php if (!empty($field['required'])) : ?> |
| | | <span class="required" aria-label="required">*</span> |
| | | <?php endif; ?> |
| | | |
| | | <?= esc_html($field['label']) ?></span> |
| | | </label> |
| | | <span class="validation-message" hidden role="alert"></span> |
| | | <?php $this->renderHintAndDescription($field, $name); ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | |
| | | |
| | | /* ========== REPEATER FIELD ========== */ |
| | | |
| | | private function renderRepeaterField(string $name, mixed $value, array $field):void |
| | | { |
| | | $values = is_array($value) ? $value : array(); |
| | | |
| | | $conditional = $this->handleConditionalField($field); |
| | | $row_label = isset($field['row_label']) ? $field['row_label'] : ''; |
| | | $rowTitle = (array_key_exists('new_row', $field)) ? $field['new_row'] : 'New Item'; |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'].'::'.$name; |
| | | } |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | $hidden = (array_key_exists('mode', $field) && $field['mode'] === 'hidden'); |
| | | if (!$hidden) { |
| | | ?> |
| | | <fieldset class="field group <?= esc_attr($name) ?>" <?= $conditional ?> data-field="<?=$name?>"<?= $describedBy?>> |
| | | <legend><?= esc_html($field['label']) ?></legend> |
| | | <?php |
| | | } |
| | | ?> |
| | | <div class="field repeater <?=$name?>" |
| | | data-field="<?= esc_attr($name); ?>" |
| | | data-field-type="<?=esc_attr($field['type'])?>" |
| | | <?= $describedBy ?> |
| | | <?= $row_label ? 'data-label="' . esc_attr($row_label) . '"' : ''; ?> |
| | | <?=$conditional?>> |
| | | <?php |
| | | if (!array_key_exists('label', $field)) { |
| | | error_log('No label for: '.print_r($name, true)); |
| | | } |
| | | ?> |
| | | <h3><?= esc_html($field['label']); ?></h3> |
| | | |
| | | <div class="group-fields <?=$original?>"<?= ($hidden) ? ' data-field="'.$name.'"' : ''?>"<?= $describedBy ?>> |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | |
| | | <div class="repeater-items"> |
| | | <?php |
| | | foreach ($field['fields'] as $field_name => $config) { |
| | | // Set the group context for proper field naming |
| | | $config['group'] = $name; |
| | | |
| | | // Get the value for this specific field |
| | | $field_value = $values[$field_name] ?? ''; |
| | | |
| | | // Handle conditional fields within the group |
| | | if (isset($config['condition'])) { |
| | | // Convert condition field reference to group context |
| | | $condition_field = $config['condition']['field']; |
| | | if (!str_contains($condition_field, '::')) { |
| | | $config['condition']['field'] = $name . '::' . $condition_field; |
| | | } |
| | | if (!empty($values)) { |
| | | foreach ($values as $index => $row) { |
| | | $this->renderRepeaterRow($field['fields'], $row, $index, $name, $rowTitle); |
| | | } |
| | | |
| | | $this->render($field_name, $field_value, $config); |
| | | } |
| | | ?> |
| | | </div> |
| | | <?php |
| | | if (!$hidden) { |
| | | ?> |
| | | </fieldset> |
| | | <?php |
| | | } |
| | | } |
| | | protected function renderLocationField(string $name, mixed $value, array $field): void |
| | | { |
| | | $googleMaps = JVB()->connect('maps'); |
| | | if (!$googleMaps->isSetUp()) { |
| | | echo '<div class="notice notice-warning"><p>Google Maps not configured. Please configure in Integrations settings.</p></div>'; |
| | | return; |
| | | } |
| | | |
| | | // Extract stored values |
| | | if (is_string($value)) { |
| | | $value = maybe_unserialize($value); |
| | | } |
| | | $stored_data = is_array($value) ? $value : []; |
| | | <template class="<?=uniqid('repeaterRow')?>"> |
| | | <?php $this->renderRepeaterRow($field['fields'], array(), '', '', $rowTitle); ?> |
| | | </template> |
| | | |
| | | $address = $stored_data['address'] ?? ''; |
| | | $lat = $stored_data['lat'] ?? ''; |
| | | $lng = $stored_data['lng'] ?? ''; |
| | | |
| | | // Generate unique field ID |
| | | $field_id = esc_attr($name); |
| | | $map_id = $field_id . '_map'; |
| | | |
| | | // Handle grouped fields |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'] . '::' . $name; |
| | | } |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | |
| | | // Prepare configuration for JavaScript initialization |
| | | $js_config = [ |
| | | 'fieldId' => $field_id, |
| | | 'initialCoords' => (!empty($lat) && !empty($lng)) ? [ |
| | | 'lat' => (float)$lat, |
| | | 'lng' => (float)$lng |
| | | ] : null |
| | | ]; |
| | | |
| | | // IMPORTANT: Properly escape the JSON for use in HTML attribute |
| | | $json_config = htmlspecialchars(json_encode($js_config), ENT_QUOTES, 'UTF-8'); |
| | | ?> |
| | | |
| | | <div class="field location <?= esc_attr($field_id) ?>" |
| | | data-field="<?= esc_attr($field_id) ?>" |
| | | data-location-field-init="<?= $json_config ?>"<?=$describedBy?>> |
| | | |
| | | <?php |
| | | if (!empty($stored_data['street'])) { |
| | | echo '<p><b>Current location:</b> '.esc_html($stored_data['street']).'</p>'; |
| | | echo '<p class="hint"><b>Search below to change:</b></p>'; |
| | | } |
| | | ?> |
| | | <button type="button" class="add-repeater-row"> |
| | | <?= jvbIcon('plus', ['title'=> 'Add']); ?> <?= (array_key_exists('add_label', $field)) ? $field['add_label'] : 'Add Item'; ?> |
| | | </button> |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | |
| | | <div class="location-search-wrapper"> |
| | | <div class="autocomplete-wrapper"></div> |
| | | |
| | | <!-- Map container --> |
| | | <div class="location-preview"> |
| | | <div id="<?= esc_attr($map_id); ?>" |
| | | class="location-map"> |
| | | </div> |
| | | |
| | | <?php if (!empty($stored_data)): |
| | | jvbLocationLinks($stored_data); |
| | | endif; ?> |
| | | </div> |
| | | |
| | | <!-- Hidden inputs for data storage --> |
| | | <input type="hidden" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[address]" |
| | | value="<?= esc_attr($address); ?>" |
| | | data-location-field="address"> |
| | | |
| | | <input type="hidden" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lat]" |
| | | value="<?= esc_attr($lat); ?>" |
| | | data-location-field="lat"> |
| | | |
| | | <input type="hidden" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lng]" |
| | | value="<?= esc_attr($lng); ?>" |
| | | data-location-field="lng"> |
| | | |
| | | <?php |
| | | // Component fields |
| | | $components = ['street', 'city', 'province', 'postal_code', 'country']; |
| | | foreach ($components as $component): |
| | | ?> |
| | | <input type="hidden" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[<?= $component; ?>]" |
| | | value="<?= esc_attr($stored_data[$component] ?? ''); ?>" |
| | | data-location-field="<?= esc_attr($component); ?>"> |
| | | <?php endforeach; ?> |
| | | |
| | | </div> |
| | | </div> |
| | | <?php |
| | | } |
| | | //TODO: This is more or less handled by PostSelector/TaxonomySelector, no? |
| | | private function renderAssociationField(string $name, mixed $value, array $field):void |
| | | { |
| | | // Ensure value is an array |
| | | if (!is_array($value)) { |
| | | $value = empty($value) ? [] : [$value]; |
| | | } |
| | | |
| | | // Get field configuration |
| | | $limit = isset($field['limit']) ? (int)$field['limit'] : 0; |
| | | $object_types = isset($field['object_types']) ? $field['object_types'] : ['post']; |
| | | $post_types = isset($field['post_types']) ? $field['post_types'] : ['post']; |
| | | $taxonomies = isset($field['taxonomies']) ? $field['taxonomies'] : []; |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | // Create unique ID for this field |
| | | $field_id = 'association-' . esc_attr($name); |
| | | $conditional = $this->handleConditionalField($field); |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'].'::'.$name; |
| | | } |
| | | ?> |
| | | <div class="field association <?=$name?>" data-field="<?= esc_attr($name); ?>" <?= $conditional; ?>> |
| | | <label><?= esc_html($field['label']); ?></label> |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | private function renderRepeaterRow(array $fields, array $values, int|string $index, string $base_name, string $rowTitle = 'New Item'):void |
| | | { |
| | | $display_number = (is_string($index)) ? $index : ($index + 1); |
| | | ?> |
| | | <div class="repeater-row" data-index="<?= esc_attr($index); ?>"> |
| | | <details <?= (is_string($index)) ? 'open' : ''; ?>> |
| | | <summary class="repeater-row-header row btw"> |
| | | <span class="drag-handle"><?= jvbIcon('dots-six-vertical'); ?></span> |
| | | <span class="row-number">#<?= esc_html($display_number); ?></span> |
| | | <span class="row-title"><?= esc_html($this->getRowTitle($fields, $values, $rowTitle)); ?></span> |
| | | <button type="button" class="remove-row" title="Remove"> |
| | | <?= jvbIcon('trash', ['title'=>'Remove']); ?> |
| | | </button> |
| | | </summary> |
| | | <div class="repeater-row-content"> |
| | | <?php |
| | | foreach ($fields as $slug => $field) : |
| | | if ($base_name === '') { |
| | | $field_name = $slug; |
| | | } else { |
| | | $field_name = sprintf('%s:%s:%s', $base_name, $index, $slug); |
| | | } |
| | | $field_value = isset($values[$slug]) ? $values[$slug] : ''; |
| | | $name = $field_name; |
| | | $this->render($name, $field_value, $field); |
| | | endforeach; |
| | | ?> |
| | | </div> |
| | | </details> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | <div class="association-container"<?=$describedBy?>> |
| | | <div class="association-search"> |
| | | <input type="text" |
| | | id="<?= esc_attr($field_id); ?>-search" |
| | | class="association-search-input" |
| | | placeholder="Search items..."> |
| | | private function getRowTitle(array $fields, array $values, string $rowTitle): string |
| | | { |
| | | // Try to find the first text field or textarea value to use as title |
| | | foreach ($fields as $slug => $field) { |
| | | if (in_array($field['type'], ['text', 'textarea']) && |
| | | isset($values[$slug]) && |
| | | !empty($values[$slug])) { |
| | | return $values[$slug]; |
| | | } |
| | | } |
| | | return $rowTitle; |
| | | } |
| | | |
| | | <div class="association-filter"> |
| | | <?php if (count($object_types) > 1 || count($post_types) > 1 || count($taxonomies) > 0) : ?> |
| | | <select class="association-filter-select"> |
| | | <?php if (in_array('post', $object_types)) : ?> |
| | | <?php foreach ($post_types as $post_type) : ?> |
| | | <?php |
| | | $post_type_obj = get_post_type_object($post_type); |
| | | $label = $post_type_obj ? $post_type_obj->labels->singular_name : ucfirst($post_type); |
| | | ?> |
| | | <option value="post:<?= esc_attr($post_type); ?>"> |
| | | <?= esc_html($label); ?> |
| | | </option> |
| | | <?php endforeach; ?> |
| | | <?php endif; ?> |
| | | /* ========== GROUP FIELD ========== */ |
| | | |
| | | <?php if (in_array('term', $object_types)) : ?> |
| | | <?php foreach ($taxonomies as $taxonomy) : ?> |
| | | <?php |
| | | $tax_obj = get_taxonomy($taxonomy); |
| | | $label = $tax_obj ? $tax_obj->labels->singular_name : ucfirst($taxonomy); |
| | | ?> |
| | | <option value="term:<?= esc_attr($taxonomy); ?>"> |
| | | <?= esc_html($label); ?> |
| | | </option> |
| | | <?php endforeach; ?> |
| | | <?php endif; ?> |
| | | </select> |
| | | <?php endif; ?> |
| | | protected function renderGroupField(string $name, mixed $value, array $field): void |
| | | { |
| | | if (!array_key_exists('fields', $field) || empty($field['fields'])) { |
| | | error_log('No fields to render'); |
| | | return; |
| | | } |
| | | |
| | | <button type="button" class="search-button"> |
| | | <?= jvbIcon('magnifying-glass', ['title' => 'Search']); ?> |
| | | </button> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="association-results"> |
| | | <div class="association-available"> |
| | | <h4>Available Items</h4> |
| | | <ul class="available-items"></ul> |
| | | <div class="association-loading" hidden> |
| | | Loading... |
| | | </div> |
| | | <div class="association-no-results" hidden> |
| | | No items found |
| | | </div> |
| | | <div class="association-pagination"> |
| | | <button type="button" class="prev-page" disabled> |
| | | <?= jvbIcon('arrow-left', ['title' => 'Previous']); ?> |
| | | </button> |
| | | <span class="page-info">Page <span class="current-page">1</span></span> |
| | | <button type="button" class="next-page" disabled> |
| | | <?= jvbIcon('arrow-right', ['title' => 'Next']); ?> |
| | | </button> |
| | | </div> |
| | | </div> |
| | | $values = is_array($value) ? $value : []; |
| | | $original = $name; |
| | | |
| | | <div class="association-actions"> |
| | | <button type="button" class="add-selected" disabled> |
| | | <?= jvbIcon('arrow-right', ['title' => 'Add selected']); ?> |
| | | </button> |
| | | <button type="button" class="remove-selected" disabled> |
| | | <?= jvbIcon('arrow-left', ['title' => 'Remove selected']); ?> |
| | | </button> |
| | | </div> |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'] . '::' . $name; |
| | | } |
| | | |
| | | <div class="association-selected"> |
| | | <h4>Selected Items |
| | | <?php if ($limit) : ?> |
| | | <span class="limit-info">(<?= esc_html($limit); ?> max)</span> |
| | | <?php endif; ?> |
| | | </h4> |
| | | <ul class="selected-items row"> |
| | | <?php |
| | | // Display currently selected items |
| | | foreach ($value as $item_id) { |
| | | // Try to determine the type and get details |
| | | $item_type = ''; |
| | | $item_title = ''; |
| | | $item_object = ''; |
| | | $hidden = (array_key_exists('mode', $field) && $field['mode'] === 'hidden'); |
| | | |
| | | // Check if it's a post |
| | | if (in_array('post', $object_types)) { |
| | | $post = get_post($item_id); |
| | | if ($post && in_array($post->post_type, $post_types)) { |
| | | $item_type = 'post'; |
| | | $item_title = $post->post_title; |
| | | $item_object = $post->post_type; |
| | | } |
| | | } |
| | | if ($hidden) { |
| | | // Simplified render for hidden groups |
| | | $this->renderGroupFields($name, $values, $field); |
| | | return; |
| | | } |
| | | |
| | | // Check if it's a term |
| | | if (empty($item_type) && in_array('term', $object_types)) { |
| | | foreach ($taxonomies as $taxonomy) { |
| | | $term = get_term($item_id, $taxonomy); |
| | | if (!is_wp_error($term) && $term) { |
| | | $item_type = 'term'; |
| | | $item_title = html_entity_decode($term->name); |
| | | $item_object = $term->taxonomy; |
| | | break; |
| | | } |
| | | } |
| | | } |
| | | // Standard fieldset render |
| | | $conditional = $this->handleConditionalField($field); |
| | | $validationAttrs = $this->buildValidationAttributes($field); |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : ''; |
| | | $fieldset = (array_key_exists('wrap', $field) && $field['wrap'] === 'details') ? 'details' : 'fieldset'; |
| | | $legend = (array_key_exists('wrap', $field) && $field['wrap'] === 'details') ? 'summary' : 'legend'; |
| | | ?> |
| | | <<?= $fieldset?> class="field group <?= esc_attr($name) ?>" |
| | | <?= $conditional ?> |
| | | data-field="<?= esc_attr($name) ?>" |
| | | data-field-type="<?=esc_attr($field['type'])?>" |
| | | <?= $validationAttrs ?> |
| | | <?= $describedBy ?>> |
| | | <<?=$legend?>><?= esc_html($field['label']) ?></<?=$legend?>> |
| | | |
| | | // Only output if we found the item |
| | | if (!empty($item_type) && !empty($item_title)) { |
| | | ?> |
| | | <li data-id="<?= esc_attr($item_id); ?>" |
| | | data-type="<?= esc_attr($item_type); ?>" |
| | | data-object="<?= esc_attr($item_object); ?>"> |
| | | <span class="item-title"><?= esc_html($item_title); ?></span> |
| | | <span class="item-type"><?= esc_html(ucfirst($item_object)); ?></span> |
| | | <button type="button" class="remove-item row"> |
| | | <?= jvbIcon('x', ['title' => 'Remove']); ?> |
| | | </button> |
| | | </li> |
| | | <?php |
| | | } |
| | | } |
| | | ?> |
| | | </ul> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <?php $this->renderHintAndDescription($field, $name); ?> |
| | | |
| | | <!-- Hidden input to store selected values --> |
| | | <input type="hidden" name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" value="<?= esc_attr(implode(',', $value)); ?>"> |
| | | </div> |
| | | <div class="group-fields <?= esc_attr($original) ?>"> |
| | | <?php $this->renderGroupFields($name, $values, $field); ?> |
| | | </div> |
| | | |
| | | <script> |
| | | (function() { |
| | | // Initialize association field |
| | | const container = document.querySelector('[data-field="<?= esc_attr($name); ?>"]'); |
| | | if (!container) return; |
| | | <span class="validation-message" hidden role="alert"></span> |
| | | </<?= $fieldset?>> |
| | | <?php |
| | | } |
| | | |
| | | const searchInput = container.querySelector('.association-search-input'); |
| | | const filterSelect = container.querySelector('.association-filter-select'); |
| | | const searchButton = container.querySelector('.search-button'); |
| | | const availableList = container.querySelector('.available-items'); |
| | | const selectedList = container.querySelector('.selected-items'); |
| | | const hiddenInput = container.querySelector('input[name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"]'); |
| | | const addButton = container.querySelector('.add-selected'); |
| | | const removeButton = container.querySelector('.remove-selected'); |
| | | const loadingIndicator = container.querySelector('.association-loading'); |
| | | const noResultsMessage = container.querySelector('.association-no-results'); |
| | | const prevPageButton = container.querySelector('.prev-page'); |
| | | const nextPageButton = container.querySelector('.next-page'); |
| | | const currentPageSpan = container.querySelector('.current-page'); |
| | | /** |
| | | * Render individual fields within a group |
| | | * Reusable for both standard and hidden group modes |
| | | */ |
| | | private function renderGroupFields(string $groupName, array $values, array $field): void |
| | | { |
| | | foreach ($field['fields'] as $field_name => $config) { |
| | | // Set the group context for proper field naming |
| | | if (!array_key_exists('wrap', $field) || $field['wrap'] !== 'details') { |
| | | $config['group'] = $groupName; |
| | | } |
| | | |
| | | // Configuration |
| | | const config = { |
| | | limit: <?= $limit ?: 0; ?>, |
| | | objectTypes: <?= json_encode($object_types); ?>, |
| | | postTypes: <?= json_encode($post_types); ?>, |
| | | taxonomies: <?= json_encode($taxonomies); ?>, |
| | | perPage: 10, |
| | | currentPage: 1 |
| | | }; |
| | | // Get the value for this specific field |
| | | $field_value = $values[$field_name] ?? ''; |
| | | |
| | | // Current state |
| | | let currentSearch = ''; |
| | | let currentFilter = filterSelect ? filterSelect.value : (config.objectTypes.includes('post') ? 'post:' + config.postTypes[0] : 'term:' + config.taxonomies[0]); |
| | | let availableItems = []; |
| | | let selectedItems = []; |
| | | // Handle conditional fields within the group |
| | | if (isset($config['condition'])) { |
| | | $condition_field = $config['condition']['field']; |
| | | if (!str_contains($condition_field, '::')) { |
| | | $config['condition']['field'] = $groupName . '::' . $condition_field; |
| | | } |
| | | } |
| | | |
| | | // Parse initial selected items |
| | | const initialValue = hiddenInput.value; |
| | | if (initialValue) { |
| | | selectedItems = initialValue.split(',').map(id => parseInt(id, 10)); |
| | | } |
| | | $this->render($field_name, $field_value, $config); |
| | | } |
| | | } |
| | | |
| | | // Event Listeners |
| | | if (searchButton) { |
| | | searchButton.addEventListener('click', performSearch); |
| | | } |
| | | |
| | | if (searchInput) { |
| | | searchInput.addEventListener('keypress', function(e) { |
| | | if (e.key === 'Enter') { |
| | | e.preventDefault(); |
| | | performSearch(); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | if (filterSelect) { |
| | | filterSelect.addEventListener('change', function() { |
| | | currentFilter = this.value; |
| | | config.currentPage = 1; |
| | | performSearch(); |
| | | }); |
| | | } |
| | | |
| | | if (prevPageButton) { |
| | | prevPageButton.addEventListener('click', function() { |
| | | if (config.currentPage > 1) { |
| | | config.currentPage--; |
| | | performSearch(); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | if (nextPageButton) { |
| | | nextPageButton.addEventListener('click', function() { |
| | | config.currentPage++; |
| | | performSearch(); |
| | | }); |
| | | } |
| | | |
| | | // Add items |
| | | addButton.addEventListener('click', function() { |
| | | const selectedAvailableItems = availableList.querySelectorAll('li.selected'); |
| | | selectedAvailableItems.forEach(item => { |
| | | const id = parseInt(item.dataset.id, 10); |
| | | // Check limit |
| | | if (config.limit && selectedItems.length >= config.limit) { |
| | | return; |
| | | } |
| | | |
| | | // Skip if already selected |
| | | if (selectedItems.includes(id)) { |
| | | return; |
| | | } |
| | | |
| | | // Add to selection |
| | | selectedItems.push(id); |
| | | |
| | | // Clone and modify for selected list |
| | | const clone = item.cloneNode(true); |
| | | clone.classList.remove('selected'); |
| | | |
| | | // Replace checkbox with remove button |
| | | const checkbox = clone.querySelector('input[type="checkbox"]'); |
| | | if (checkbox) { |
| | | const removeBtn = document.createElement('button'); |
| | | removeBtn.type = 'button'; |
| | | removeBtn.className = 'remove-item'; |
| | | removeBtn.innerHTML = '<?= jvbIcon('x', ['title' => 'Remove']); ?>'; |
| | | removeBtn.addEventListener('click', function() { |
| | | removeItem(id, clone); |
| | | }); |
| | | |
| | | checkbox.parentNode.replaceChild(removeBtn, checkbox); |
| | | } |
| | | |
| | | selectedList.appendChild(clone); |
| | | }); |
| | | |
| | | // Update hidden input |
| | | updateHiddenInput(); |
| | | |
| | | // Update UI state |
| | | updateButtonStates(); |
| | | }); |
| | | |
| | | // Remove items |
| | | removeButton.addEventListener('click', function() { |
| | | const selectedSelectedItems = selectedList.querySelectorAll('li.selected'); |
| | | selectedSelectedItems.forEach(item => { |
| | | const id = parseInt(item.dataset.id, 10); |
| | | removeItem(id, item); |
| | | }); |
| | | }); |
| | | |
| | | // Listen for clicks on items in both lists |
| | | availableList.addEventListener('click', function(e) { |
| | | const item = e.target.closest('li'); |
| | | if (!item) return; |
| | | |
| | | // If clicking checkbox, handle separately |
| | | if (e.target.type === 'checkbox') { |
| | | updateButtonStates(); |
| | | return; |
| | | } |
| | | |
| | | // Toggle selection |
| | | if (item.classList.contains('selected')) { |
| | | item.classList.remove('selected'); |
| | | item.querySelector('input[type="checkbox"]').checked = false; |
| | | } else { |
| | | item.classList.add('selected'); |
| | | item.querySelector('input[type="checkbox"]').checked = true; |
| | | } |
| | | |
| | | updateButtonStates(); |
| | | }); |
| | | |
| | | selectedList.addEventListener('click', function(e) { |
| | | const item = e.target.closest('li'); |
| | | if (!item) return; |
| | | |
| | | // If clicking remove button, handle it |
| | | if (e.target.closest('.remove-item')) { |
| | | const id = parseInt(item.dataset.id, 10); |
| | | removeItem(id, item); |
| | | return; |
| | | } |
| | | |
| | | // Toggle selection |
| | | item.classList.toggle('selected'); |
| | | updateButtonStates(); |
| | | }); |
| | | |
| | | // Helper Functions |
| | | function performSearch() { |
| | | currentSearch = searchInput.value.trim(); |
| | | |
| | | // Show loading |
| | | loadingIndicator.hidden = false; |
| | | noResultsMessage.hidden = true; |
| | | availableList.innerHTML = ''; |
| | | |
| | | // Get filter parts |
| | | const [type, object] = currentFilter.split(':'); |
| | | |
| | | // Prepare data for AJAX |
| | | const data = { |
| | | action: 'jvb_association_search', |
| | | nonce: jvbSettings.nonce, |
| | | type: type, |
| | | object: object, |
| | | search: currentSearch, |
| | | page: config.currentPage, |
| | | per_page: config.perPage, |
| | | selected: selectedItems |
| | | }; |
| | | |
| | | // Make AJAX request to WordPress REST API |
| | | fetch(jvbSettings.api + 'terms', { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/json', |
| | | 'X-WP-Nonce': jvbSettings.nonce |
| | | }, |
| | | body: JSON.stringify(data) |
| | | }) |
| | | .then(response => response.json()) |
| | | .then(response => { |
| | | // Hide loading |
| | | loadingIndicator.hidden = true; |
| | | |
| | | if (response.success && response.items && response.items.length > 0) { |
| | | // Update available items |
| | | availableItems = response.items; |
| | | |
| | | // Render items |
| | | renderAvailableItems(); |
| | | |
| | | // Update pagination |
| | | updatePagination(response.total, response.pages); |
| | | } else { |
| | | // Show no results |
| | | noResultsMessage.hidden = false; |
| | | prevPageButton.disabled = true; |
| | | nextPageButton.disabled = true; |
| | | currentPageSpan.textContent = '1'; |
| | | } |
| | | }) |
| | | .catch(error => { |
| | | console.error('Error searching items:', error); |
| | | loadingIndicator.hidden = true; |
| | | noResultsMessage.hidden = false; |
| | | }); |
| | | } |
| | | |
| | | function renderAvailableItems() { |
| | | availableList.innerHTML = ''; |
| | | |
| | | availableItems.forEach(item => { |
| | | const isSelected = selectedItems.includes(item.id); |
| | | |
| | | const li = document.createElement('li'); |
| | | li.dataset.id = item.id; |
| | | li.dataset.type = item.type; |
| | | li.dataset.object = item.object; |
| | | |
| | | // Create checkbox |
| | | const checkbox = document.createElement('input'); |
| | | checkbox.type = 'checkbox'; |
| | | checkbox.id = `${name}-item-${item.id}`; |
| | | |
| | | // Create label for title |
| | | const titleSpan = document.createElement('span'); |
| | | titleSpan.className = 'item-title'; |
| | | titleSpan.textContent = item.title; |
| | | |
| | | // Create label for type |
| | | const typeSpan = document.createElement('span'); |
| | | typeSpan.className = 'item-type'; |
| | | typeSpan.textContent = item.object_label; |
| | | |
| | | // Append elements |
| | | li.appendChild(checkbox); |
| | | li.appendChild(titleSpan); |
| | | li.appendChild(typeSpan); |
| | | |
| | | // Disable if already selected |
| | | if (isSelected) { |
| | | li.classList.add('disabled'); |
| | | checkbox.disabled = true; |
| | | |
| | | // Add note that item is already selected |
| | | const note = document.createElement('span'); |
| | | note.className = 'item-note'; |
| | | note.textContent = 'Already selected'; |
| | | li.appendChild(note); |
| | | } |
| | | |
| | | availableList.appendChild(li); |
| | | }); |
| | | } |
| | | |
| | | function updatePagination(total, pages) { |
| | | // Update current page display |
| | | currentPageSpan.textContent = config.currentPage; |
| | | |
| | | // Update prev/next buttons |
| | | prevPageButton.disabled = config.currentPage <= 1; |
| | | nextPageButton.disabled = config.currentPage >= pages; |
| | | } |
| | | |
| | | function removeItem(id, element) { |
| | | // Remove from array |
| | | selectedItems = selectedItems.filter(itemId => itemId !== id); |
| | | |
| | | // Remove from DOM |
| | | if (element) { |
| | | element.remove(); |
| | | } |
| | | |
| | | // Update hidden input |
| | | updateHiddenInput(); |
| | | |
| | | // Update buttons |
| | | updateButtonStates(); |
| | | } |
| | | |
| | | function updateHiddenInput() { |
| | | hiddenInput.value = selectedItems.join(','); |
| | | } |
| | | |
| | | function updateButtonStates() { |
| | | // Add button is enabled if at least one available item is selected |
| | | // and we haven't reached the limit |
| | | const hasSelectedAvailable = availableList.querySelector('li.selected:not(.disabled)') !== null; |
| | | addButton.disabled = !hasSelectedAvailable || |
| | | (config.limit > 0 && selectedItems.length >= config.limit); |
| | | |
| | | // Remove button is enabled if at least one selected item is selected |
| | | const hasSelectedItems = selectedList.querySelector('li.selected') !== null; |
| | | removeButton.disabled = !hasSelectedItems; |
| | | } |
| | | |
| | | // Initial search |
| | | performSearch(); |
| | | })(); |
| | | </script> |
| | | <?php |
| | | } |
| | | |
| | | private function renderTrueFalseField(string $name, mixed $value, array $field):void |
| | | { |
| | | $conditional = $this->handleConditionalField($field); |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'].'::'.$name; |
| | | } |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | ?> |
| | | <div class="field true-false <?=$name?> row btw" <?=$conditional?> data-field="<?=$name?>"> |
| | | <label class="toggle-switch row"<?=$describedBy?>> |
| | | <input |
| | | type="checkbox" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | value="1" |
| | | <?= ($value) ? ' checked':'' ?> |
| | | <?= !empty($field['required']) ? 'required' : ''; ?> |
| | | > |
| | | <div class="slider"></div> |
| | | <span class="toggle-label"><?= esc_html($field['label']); ?></span> |
| | | </label> |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | /* ========== UPLOAD FIELD ========== */ |
| | | private function renderGalleryField(string $name, mixed $value, array $field):void |
| | | { |
| | | $field['multiple'] = true; |
| | | $this->renderUploadField($name, $value, $field); |
| | | } |
| | | private function renderUploadField(string $name, mixed $value, array $field): void |
| | | { |
| | | $defaultConfig = [ |
| | |
| | | //Processing Options |
| | | 'max_size' => null, // Override default size limits |
| | | 'convert' => 'webp', // Image conversion format |
| | | 'quality' => 80, // Conversion quality |
| | | 'quality' => 90, // Conversion quality |
| | | 'create_thumbnails' => true, |
| | | ]; |
| | | $config = array_merge($defaultConfig, $field); |
| | |
| | | |
| | | // Build accept attribute for input |
| | | $acceptExtensions = $this->getMimeExtensions($acceptedTypes); |
| | | $acceptAttr = implode(',', $acceptExtensions); |
| | | $acceptAttr = implode(',', $acceptedTypes); |
| | | |
| | | // Determine field attributes |
| | | $subtype = $config['subtype'] ?? 'image'; |
| | |
| | | } |
| | | ?> |
| | | <div class="field upload <?= esc_attr($name) ?>" |
| | | data-field="<?=esc_attr($name)?>" |
| | | data-field-type="upload" |
| | | <?= $dataAttrString ?> |
| | | <?= $conditional ?>> |
| | | |
| | |
| | | <?php endif; ?> |
| | | <div class="file-error"></div> |
| | | </div> |
| | | <?php jvbRenderProgressBar(); ?> |
| | | </div> |
| | | |
| | | |
| | |
| | | <div class="selection-controls"> |
| | | <div class="selected"> |
| | | <div class="field"> |
| | | <input type="checkbox" id="select-all-uploads" name="select-all-uploads"> |
| | | <input type="checkbox" id="select-all-uploads" data-select-all data-selects="item-grid" name="select-all-uploads"> |
| | | <label for="select-all-uploads"> |
| | | Select All |
| | | </label> |
| | |
| | | <?php |
| | | } |
| | | |
| | | |
| | | protected function getAllowedTypes(array $config):array |
| | | private function renderExistingAttachment(int $attachmentId, string $subtype): string |
| | | { |
| | | $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']]; |
| | | ob_start(); |
| | | |
| | | switch ($subtype) { |
| | | case 'image': |
| | | $this->renderImagePreview($attachmentId); |
| | | break; |
| | | case 'video': |
| | | $this->renderVideoPreview($attachmentId); |
| | | break; |
| | | case 'document': |
| | | case 'file': |
| | | $this->renderFilePreview($attachmentId); |
| | | break; |
| | | default: |
| | | $this->renderImagePreview($attachmentId); |
| | | break; |
| | | } |
| | | |
| | | // 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); |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | | /** |
| | |
| | | |
| | | 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 |
| | | */ |
| | |
| | | } |
| | | |
| | | /** |
| | | * Render existing attachment |
| | | * Render upload preview items |
| | | */ |
| | | private function renderExistingAttachment(int $attachmentId, string $subtype): string |
| | | private function renderUploadPreviews(array $attachmentIds, array $config): void |
| | | { |
| | | $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; |
| | | if ($value !== 0 || $value !== '') { |
| | | $image_url = wp_get_attachment_image_url((int)$value, 'medium') ?: false; |
| | | $caption = wp_get_attachment_caption((int)$value); |
| | | $alt = get_post_meta((int)$value, '_wp_attachment_image_alt', true); |
| | | $title = get_the_title((int)$value); |
| | | if (empty($attachmentIds)) { |
| | | return; |
| | | } |
| | | |
| | | $mode = array_key_exists('mode', $field) ? $field['mode'] : 'direct'; |
| | | $multiple = ($mode === 'selection' || isset($field['multiple'])); |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'].'::'.$name; |
| | | } |
| | | $groupable = (array_key_exists('imageType', $field) && $field['imageType'] === 'groupable'); |
| | | $singular = (array_key_exists('singular', $field)) ? $field['singular'] : 'post'; |
| | | $plural = (array_key_exists('plural', $field)) ? $field['plural'] : 'posts'; |
| | | $dataContent = (array_key_exists('content', $field)) ? ' data-content="'.$field['content'].'"' : ''; |
| | | $dataType = ($groupable) ? 'groupable' : (($multiple) ? 'gallery' : 'single'); |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | ?> |
| | | <div class="field image <?=$name?>" |
| | | data-field="<?= esc_attr($name); ?>" |
| | | data-upload-field |
| | | data-mode="<?= esc_attr($mode); ?>" |
| | | <?=$dataContent?> |
| | | <?= ' data-type="'.$dataType.'"'?>> |
| | | foreach ($attachmentIds as $id) { |
| | | switch ($config['subtype']) { |
| | | case 'image': |
| | | $this->renderImagePreview($id, $config); |
| | | break; |
| | | case 'video': |
| | | $this->renderVideoPreview($id, $config); |
| | | break; |
| | | case 'file': |
| | | $this->renderFilePreview($id, $config); |
| | | break; |
| | | } |
| | | } |
| | | } |
| | | |
| | | <div class="file-upload-container"> |
| | | <div class="file-upload-wrapper"> |
| | | <input type="file" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>_temp" |
| | | id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>_temp" |
| | | accept=".jpg,.jpeg,.png,.gif,.webp" |
| | | data-max-size="<?= $this->max_file_size; ?>" |
| | | <?= $multiple ? 'multiple' : ''; ?>> |
| | | <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> |
| | | JPG, PNG, GIF, or WEBP (max. 5MB) |
| | | </p> |
| | | <?php if ($groupable) { ?> |
| | | <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 } ?> |
| | | <?php if (!empty($field['upload_description'])) : ?> |
| | | <p><?= esc_html($field['upload_description']); ?></p> |
| | | <?php endif; ?> |
| | | </div> |
| | | <div class="file-error"></div> |
| | | </div> |
| | | <?php if ($groupable) : ?> |
| | | <div class="group-display" hidden> |
| | | <div class="preview-wrap"> |
| | | <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> |
| | | With <span class="selection-count">0</span> selected |
| | | </div> |
| | | </div> |
| | | |
| | | |
| | | <!-- Selection actions (hidden by default) --> |
| | | <div class="selection-actions" hidden> |
| | | <button type="button" class="create-from-selection"> |
| | | <?= jvbIcon('plus-square') ?> |
| | | Create New <?= $singular ?> |
| | | </button> |
| | | <button type="button" class="remove-selection"> |
| | | <?= jvbIcon('trash') ?> |
| | | Remove |
| | | </button> |
| | | </div> |
| | | </div> |
| | | |
| | | <button type="button" class="submit-uploads"> |
| | | <?= jvbIcon('cloud-arrow-up') ?> 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 if ($image_url) { |
| | | echo jvbRenderImageForm((int)$value); |
| | | } ?> |
| | | public function renderImagePreview(?int $id = null, array $config = []):void |
| | | { |
| | | $attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', false) : false; |
| | | $caption = ($id) ? wp_get_attachment_caption($id) : ''; |
| | | $alt = ($id) ? get_post_meta($id, '_wp_attachment_image_alt',true) : ''; |
| | | $title = ($id) ? get_the_title($id) : ''; |
| | | $addID = ($id) ? '-'.$id : ''; |
| | | $dataID = ($id) ? ['id' => $id] : ''; |
| | | ?> |
| | | <div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>> |
| | | <div class="preview"> |
| | | <?php jvbRenderProgressBar('',true) ?> |
| | | <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>"> |
| | | <label for="select-item<?=$addID?>" aria-label="Select image"> |
| | | <?= ($attachment) ?: '<img> |
| | | <video></video> |
| | | <span></span>' ?> |
| | | </label> |
| | | <div class="item-actions row btw"> |
| | | <div class="radio-button"> |
| | | <input type="radio" class="featured btn" name="featured" id="featured" hidden> |
| | | <label for="featured"> |
| | | <?=jvbIcon('star')?> |
| | | <?=jvbIcon('star', ['style' => 'fill'])?> |
| | | <span class="screen-reader-text">Set as featured image</span> |
| | | </label> |
| | | </div> |
| | | |
| | | <?php if ($groupable) : ?> |
| | | <p class="hint"><?= jvbIcon('arrow-elbow-left-up') ?> These will become individual <?= $plural ?> <?= jvbIcon('arrow-elbow-right-up')?></p> |
| | | </div> |
| | | <div class="sidebar"> |
| | | <div class="header"> |
| | | <h4>New <?= $plural?></h4> |
| | | <p class="hint">Drag images into groups to create separate <?= $plural ?>.</p> |
| | | <p class="hint">Select multiple images and click "Add to <?= $singular?>" or create new <?= $plural ?>.</p> |
| | | </div> |
| | | <button type="button" class="create-group-from-selection"> |
| | | <?= jvbIcon('plus-square') ?> |
| | | Create New <?= $singular ?> |
| | | <button type="button" data-action="delete-upload" title="Remove from Group"> |
| | | <?=jvbIcon('trash')?> |
| | | </button> |
| | | <div class="item-grid groups"> |
| | | <div class="empty-group"> |
| | | <p>Drag here to create a new <?= $singular ?>!</p> |
| | | </div> |
| | | </div> |
| | | <p class="hint"><?= jvbIcon('arrow-elbow-left-up') ?> Each group will become its own <?= $singular ?> <?= jvbIcon('arrow-elbow-right-up')?></p> |
| | | </div> |
| | | </div> |
| | | <?php endif; ?> |
| | | <details> |
| | | <summary class="row btw"><?=jvbIcon('pencil-simple')?><span>Edit Info</span></summary> |
| | | |
| | | <?php if ($mode === 'direct') : ?> |
| | | <?php |
| | | |
| | | $fields = [ |
| | | 'image_data' => [ |
| | | 'type' => 'group', |
| | | 'wrap' => 'details', |
| | | 'label' => 'Image Fields', |
| | | 'fields' => [ |
| | | 'image-title'.$addID => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Image Title', |
| | | 'value' => $title, |
| | | 'data' => $dataID |
| | | ], |
| | | 'image-alt-text'.$addID => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Alt Text', |
| | | 'value' => $alt, |
| | | 'hint' => 'Alt text helps the visually impaired, as well as some benefits for SEO.', |
| | | 'data' => $dataID |
| | | ], |
| | | 'image-caption'.$addID => [ |
| | | 'type' => 'textarea', |
| | | 'value' => $caption, |
| | | 'label' => 'Image Caption', |
| | | 'data' => $dataID |
| | | ] |
| | | ] |
| | | ] |
| | | ]; |
| | | $fields = array_key_exists('fields', $config) ? array_merge($fields, $config['fields']) : $fields; |
| | | $meta = new MetaManager($id); |
| | | foreach ($fields as $field => $config) { |
| | | $meta->render('form', $field, $config); |
| | | } |
| | | ?> |
| | | </details> |
| | | </div> |
| | | <?php |
| | | } |
| | | public function renderVideoPreview(?int $id = null, array $config = []):void |
| | | { |
| | | $attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', true) : false; |
| | | $caption = ($id) ? wp_get_attachment_caption($id) : ''; |
| | | $description = ($id) ? get_the_content($id) : ''; |
| | | $title = ($id) ? get_the_title($id) : ''; |
| | | $addID = ($id) ? '-'.$id : ''; |
| | | $dataID = ($id) ? ['id' => $id] : ''; |
| | | ?> |
| | | <div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>> |
| | | <div class="preview"> |
| | | <?php jvbRenderProgressBar('',true) ?> |
| | | <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>"> |
| | | <label for="select-item<?=$addID?>" aria-label="Select image"> |
| | | <?= ($attachment) ?: '<img> |
| | | <video></video> |
| | | <span></span>'; ?> |
| | | </label> |
| | | <div class="item-actions row btw"> |
| | | <div class="radio-button"> |
| | | <input type="radio" class="featured btn" name="featured" id="featured" hidden> |
| | | <label for="featured"> |
| | | <?=jvbIcon('star')?> |
| | | <?=jvbIcon('star', ['style' => 'fill'])?> |
| | | <span class="screen-reader-text">Set as featured image</span> |
| | | </label> |
| | | </div> |
| | | |
| | | <button type="button" data-action="delete-upload" title="Remove from Group"> |
| | | <?=jvbIcon('trash')?> |
| | | </button> |
| | | </div> |
| | | </div> |
| | | <details>'; |
| | | <summary class="row btw"><?=jvbIcon('pencil-simple')?><span>Edit Info</span></summary> |
| | | |
| | | <?php |
| | | $fields = array_key_exists('fields', $config) ? $config['fields'] : []; |
| | | $fields = array_merge([ |
| | | 'upload_data' => [ |
| | | 'type' => 'group', |
| | | 'wrap' => 'details', |
| | | 'label' => 'Video Info', |
| | | 'hint' => 'These will be automatically generated if left blank.', |
| | | 'fields' => [ |
| | | 'title' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Video Title', |
| | | 'value' => $title, |
| | | 'data' => $dataID |
| | | ], |
| | | 'caption' => [ |
| | | 'type' => 'textarea', |
| | | 'value' => $caption, |
| | | 'label' => 'Video Caption', |
| | | 'data' => $dataID |
| | | ], |
| | | 'description' => [ |
| | | 'type' => 'textarea', |
| | | 'value' => $description, |
| | | 'label' => 'Video Description', |
| | | 'data' => $dataID |
| | | ] |
| | | ] |
| | | ] |
| | | ], $fields); |
| | | $this->render('upload_data', null, $fields); |
| | | ?> |
| | | </details> |
| | | </div> |
| | | <?php |
| | | } |
| | | public function renderFilePreview(?int $id = null, array $config = []):void |
| | | { |
| | | |
| | | $attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', true) : false; |
| | | $caption = ($id) ? wp_get_attachment_caption($id) : ''; |
| | | $description = ($id) ? get_the_content($id) : ''; |
| | | $title = ($id) ? get_the_title($id) : ''; |
| | | $addID = ($id) ? '-'.$id : ''; |
| | | $dataID = ($id) ? ['id' => $id] : ''; |
| | | ?> |
| | | <div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>> |
| | | <div class="preview"> |
| | | <?php jvbRenderProgressBar('',true) ?> |
| | | <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>"> |
| | | <label for="select-item<?=$addID?>" aria-label="Select image"> |
| | | <?= ($attachment) ?: '<img> |
| | | <video></video> |
| | | <span></span>'; ?> |
| | | </label> |
| | | <div class="item-actions row btw"> |
| | | <div class="radio-button"> |
| | | <input type="radio" class="featured btn" name="featured" id="featured" hidden> |
| | | <label for="featured"> |
| | | <?=jvbIcon('star')?> |
| | | <?=jvbIcon('star', ['style' => 'fill'])?> |
| | | <span class="screen-reader-text">Set as featured image</span> |
| | | </label> |
| | | </div> |
| | | |
| | | <button type="button" data-action="delete-upload" title="Remove from Group"> |
| | | <?=jvbIcon('trash')?> |
| | | </button> |
| | | </div> |
| | | </div> |
| | | <details>'; |
| | | <summary class="row btw"><?=jvbIcon('pencil-simple')?><span>Edit Info</span></summary> |
| | | |
| | | <?php |
| | | $fields = array_key_exists('fields', $config) ? $config['fields'] : []; |
| | | $fields = array_merge([ |
| | | 'upload_data' => [ |
| | | 'type' => 'group', |
| | | 'wrap' => 'details', |
| | | 'label' => 'File Info', |
| | | 'hint' => 'These will be automatically generated if left blank.', |
| | | 'fields' => [ |
| | | 'title' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'File Title', |
| | | 'value' => $title, |
| | | 'data' => $dataID |
| | | ], |
| | | 'caption' => [ |
| | | 'type' => 'textarea', |
| | | 'value' => $caption, |
| | | 'label' => 'File Caption', |
| | | 'data' => $dataID |
| | | ], |
| | | 'description' => [ |
| | | 'type' => 'textarea', |
| | | 'value' => $description, |
| | | 'label' => 'File Description', |
| | | 'data' => $dataID |
| | | ] |
| | | ] |
| | | ] |
| | | ], $fields); |
| | | $this->render('upload_data', null, $fields); |
| | | ?> |
| | | </details> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | /** |
| | | * Get upload instruction text based on config |
| | | */ |
| | | private function getUploadInstructions(array $config): string |
| | | { |
| | | $extensions = $this->getMimeExtensions($this->getAllowedTypes($config)); |
| | | $extList = implode(', ', array_map('strtoupper', $extensions)); |
| | | $maxSize = $config['max_size'] ?? $this->max_file_size; |
| | | $maxSizeMB = round($maxSize / 1048576, 1); |
| | | |
| | | return "{$extList} (max. {$maxSizeMB}MB)"; |
| | | } |
| | | |
| | | /* ========== TAXONOMY/USER SELECTOR FIELDS ========== */ |
| | | |
| | | private function renderTaxonomyField(string $name, string $value, array $field): void |
| | | { |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'] . '::' . $name; |
| | | } |
| | | |
| | | $this->renderSelectorField($name, $value, $field, 'taxonomy'); |
| | | } |
| | | |
| | | private function renderUserField(string $name, string $value, array $field): void |
| | | { |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'] . '::' . $name; |
| | | } |
| | | |
| | | $this->renderSelectorField($name, $value, $field, 'post'); |
| | | } |
| | | |
| | | /** |
| | | * Generic selector field renderer |
| | | * Handles both taxonomy and post selectors with consistent structure |
| | | */ |
| | | public function renderSelectorField(string $name, mixed $value, array $field, string $type): void |
| | | { |
| | | $conditional = $this->handleConditionalField($field); |
| | | $validationAttrs = $this->buildValidationAttributes($field); |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : ''; |
| | | |
| | | // Parse selected values |
| | | $value = (is_array($value)) ? array_filter(array_map('absint', $value)): $value; |
| | | $selected = ($value === '') ? [] : (is_array($value) ? $value : explode(',', $value)); |
| | | |
| | | // Generate unique container ID |
| | | $containerId = $name . '-' . $type . '-selector'; |
| | | |
| | | // Create selector instance with proper parameters |
| | | if ($type === 'taxonomy') { |
| | | $taxonomy = $field['taxonomy']; |
| | | $icon = JVB_TAXONOMY[$taxonomy]['icon']??''; |
| | | |
| | | // Map field config to selector config |
| | | $selectorConfig = [ |
| | | 'max' => $field['max'] ?? 0, // 0 = unlimited |
| | | 'search' => $field['search'] ?? true, |
| | | 'label' => $field['label'] ?? '', |
| | | 'createNew' => $field['createNew'] ?? false, |
| | | 'required' => $field['required'] ?? false, |
| | | 'base' => $field['base'] ?? '', |
| | | 'update' => $field['update'] ?? true, |
| | | 'name' => $name, |
| | | 'autocomplete' => $field['autocomplete'] ?? false, |
| | | ]; |
| | | if ($icon !== '') { |
| | | $selectorConfig['icon'] = $icon; |
| | | } |
| | | |
| | | $selector = new TaxonomySelector($containerId, $taxonomy, $selectorConfig); |
| | | $icon = $taxonomy; |
| | | } else { |
| | | $postType = $field['post_type']; |
| | | |
| | | // Map field config to selector config |
| | | $selectorConfig = [ |
| | | 'max' => $field['max'] ?? 0, |
| | | 'search' => $field['search'] ?? true, |
| | | 'label' => $field['label'] ?? '', |
| | | 'required' => $field['required'] ?? false, |
| | | 'base' => $field['base'] ?? '', |
| | | 'update' => $field['update'] ?? true, |
| | | 'shop_id' => $field['shop_id'] ?? null, |
| | | 'autocomplete'=> $field['autocomplete'] ?? true, |
| | | ]; |
| | | |
| | | $selector = new PostSelector($containerId, $postType, $selectorConfig); |
| | | $icon = $postType; |
| | | } |
| | | |
| | | ?> |
| | | <div class="field selector <?= esc_attr($type) ?> <?= esc_attr($name) ?>" |
| | | <?= $conditional ?> |
| | | data-field="<?= esc_attr($name) ?>" |
| | | data-field-type="selector" |
| | | data-type="<?=esc_attr($field['type'])?>" |
| | | <?= $validationAttrs ?> |
| | | <?= $describedBy ?>> |
| | | |
| | | <?= $selector->render($selected) ?> |
| | | |
| | | <!-- Hidden input for form submission --> |
| | | <input type="hidden" |
| | | class="<?= esc_attr($type) ?>-selector-input" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : '' ?><?= esc_attr($name) ?>" |
| | | data-<?= esc_attr($type) ?>="<?= esc_attr($field[$type === 'taxonomy' ? 'taxonomy' : 'post_type']) ?>" |
| | | value="<?= esc_attr(is_array($selected) ? implode(',', $selected) : $value) ?>" |
| | | <?= !empty($field['required']) ? 'required' : '' ?>> |
| | | |
| | | <?php $this->renderHintAndDescription($field, $name); ?> |
| | | |
| | | <span class="validation-message" hidden role="alert"></span> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | /* ========== LOCATION FIELD ========== */ |
| | | |
| | | protected function renderLocationField(string $name, mixed $value, array $field): void |
| | | { |
| | | $googleMaps = JVB()->connect('maps'); |
| | | if (!$googleMaps->isSetUp()) { |
| | | echo '<div class="notice notice-warning"><p>Google Maps not configured. Please configure in Integrations settings.</p></div>'; |
| | | return; |
| | | } |
| | | |
| | | // Extract stored values |
| | | if (is_string($value)) { |
| | | $value = maybe_unserialize($value); |
| | | } |
| | | $stored_data = is_array($value) ? $value : []; |
| | | |
| | | $address = $stored_data['address'] ?? ''; |
| | | $lat = $stored_data['lat'] ?? ''; |
| | | $lng = $stored_data['lng'] ?? ''; |
| | | |
| | | // Generate unique field ID |
| | | $field_id = esc_attr($name); |
| | | $map_id = $field_id . '_map'; |
| | | |
| | | // Handle grouped fields |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'] . '::' . $name; |
| | | } |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | |
| | | // Prepare configuration for JavaScript initialization |
| | | $js_config = [ |
| | | 'fieldId' => $field_id, |
| | | 'initialCoords' => (!empty($lat) && !empty($lng)) ? [ |
| | | 'lat' => (float)$lat, |
| | | 'lng' => (float)$lng |
| | | ] : null |
| | | ]; |
| | | |
| | | // IMPORTANT: Properly escape the JSON for use in HTML attribute |
| | | $json_config = htmlspecialchars(json_encode($js_config), ENT_QUOTES, 'UTF-8'); |
| | | ?> |
| | | |
| | | <div class="field location <?= esc_attr($field_id) ?>" |
| | | data-field="<?= esc_attr($field_id) ?>" |
| | | data-field-type="<?=esc_attr($field['type'])?>" |
| | | data-location-field-init="<?= $json_config ?>"<?=$describedBy?>> |
| | | |
| | | <?php |
| | | if (!empty($stored_data['street'])) { |
| | | echo '<p><b>Current location:</b> '.esc_html($stored_data['street']).'</p>'; |
| | | echo '<p class="hint"><b>Search below to change:</b></p>'; |
| | | } |
| | | ?> |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | |
| | | <div class="location-search-wrapper"> |
| | | <div class="autocomplete-wrapper"></div> |
| | | |
| | | <!-- Map container --> |
| | | <div class="location-preview"> |
| | | <div id="<?= esc_attr($map_id); ?>" |
| | | class="location-map"> |
| | | </div> |
| | | |
| | | <?php if (!empty($stored_data)): |
| | | jvbLocationLinks($stored_data); |
| | | endif; ?> |
| | | </div> |
| | | |
| | | <!-- Hidden inputs for data storage --> |
| | | <input type="hidden" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | value="<?= esc_attr($value); ?>" |
| | | <?= !empty($field['required']) ? 'required' : ''; ?>> |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[address]" |
| | | value="<?= esc_attr($address); ?>" |
| | | data-location-field="address"> |
| | | |
| | | <input type="hidden" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lat]" |
| | | value="<?= esc_attr($lat); ?>" |
| | | data-location-field="lat"> |
| | | |
| | | <input type="hidden" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lng]" |
| | | value="<?= esc_attr($lng); ?>" |
| | | data-location-field="lng"> |
| | | |
| | | <?php |
| | | // Component fields |
| | | $components = ['street', 'city', 'province', 'postal_code', 'country']; |
| | | foreach ($components as $component): |
| | | ?> |
| | | <input type="hidden" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[<?= $component; ?>]" |
| | | value="<?= esc_attr($stored_data[$component] ?? ''); ?>" |
| | | data-location-field="<?= esc_attr($component); ?>"> |
| | | <?php endforeach; ?> |
| | | |
| | | </div> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | /* ========== HTML FIELD ========== */ |
| | | |
| | | protected function renderHtmlField(string $name, mixed $value, array $field): void |
| | | { |
| | | $method_name = $field['content']; |
| | | $content = ''; |
| | | |
| | | if (method_exists($this, $method_name)) { |
| | | $content = $this->$method_name(); |
| | | } |
| | | |
| | | if ($content === '') { |
| | | return; |
| | | } |
| | | |
| | | echo sprintf( |
| | | '<div class="html-field-container" data-field-type="html" data-field="%s">%s</div>', |
| | | esc_attr($name), |
| | | $content |
| | | ); |
| | | } |
| | | |
| | | /* ========== UTILITY METHODS ========== */ |
| | | |
| | | private function handleConditionalField(array $field):string |
| | | { |
| | | if (empty($field['condition'])) { |
| | | return ''; |
| | | } |
| | | |
| | | $condition = $field['condition']; |
| | | return sprintf( |
| | | 'data-depends-on="%s" data-depends-value="%s" data-depends-operator="%s"', |
| | | esc_attr($field['condition']['field']), |
| | | esc_attr($field['condition']['value']), |
| | | esc_attr($field['condition']['operator'] ?? '==') |
| | | ); |
| | | } |
| | | |
| | | protected function getAllowedTypes(array $config): array |
| | | { |
| | | if (!empty($config['accepted_types'])) { |
| | | return $config['accepted_types']; |
| | | } |
| | | |
| | | // Default types based on subtype |
| | | $defaults = [ |
| | | 'image' => ['image/*'], |
| | | 'video' => ['video/*'], |
| | | 'document' => ['application/pdf', 'application/msword', 'application/vnd.ms-excel', 'text/plain', '.odt','application/vnd.openxmlformats-officedocument.wordprocessingml.document'], |
| | | ]; |
| | | $defaults['any'] = array_merge($defaults['image'], $defaults['video'], $defaults['document']); |
| | | |
| | | return $defaults[$config['subtype']] ?? $defaults['image']; |
| | | } |
| | | |
| | | protected function getMimeExtensions(array $mimeTypes): array |
| | | { |
| | | $extensions = []; |
| | | foreach ($mimeTypes as $mime) { |
| | | if (str_contains($mime, '*')) { |
| | | continue; // Skip wildcards |
| | | } |
| | | $ext = str_replace(['image/', 'video/', 'application/'], '', $mime); |
| | | $extensions[] = '.' . $ext; |
| | | } |
| | | return $extensions; |
| | | } |
| | | |
| | | protected function parseAttachmentIds(mixed $value): array |
| | | { |
| | | if (empty($value)) { |
| | | return []; |
| | | } |
| | | |
| | | if (is_array($value)) { |
| | | return array_filter($value, 'is_numeric'); |
| | | } |
| | | |
| | | if (is_string($value)) { |
| | | return array_filter(explode(',', $value), 'is_numeric'); |
| | | } |
| | | |
| | | return is_numeric($value) ? [$value] : []; |
| | | } |
| | | /** |
| | | * Render tag list field - inline tag input interface |
| | | */ |
| | | protected function renderTagListField(string $name, mixed $value, array $field): void |
| | | { |
| | | $values = is_array($value) ? $value : []; |
| | | $conditional = $this->handleConditionalField($field); |
| | | $validationAttrs = $this->buildValidationAttributes($field); |
| | | |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'] . '::' . $name; |
| | | } |
| | | |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : ''; |
| | | |
| | | // Tag display format - defaults to first field value |
| | | $tagFormat = $field['tag_format'] ?? 'first_field'; |
| | | ?> |
| | | <div class="field tag-list <?= esc_attr($name) ?>" |
| | | data-field="<?= esc_attr($name) ?>" |
| | | data-field-type="<?=esc_attr($field['type'])?>" |
| | | data-tag-format="<?= esc_attr($tagFormat) ?>" |
| | | <?= $describedBy ?> |
| | | <?= $conditional ?> |
| | | <?= $validationAttrs ?>> |
| | | |
| | | <?php if (!empty($field['label'])): ?> |
| | | <h3><?= esc_html($field['label']) ?></h3> |
| | | <?php endif; ?> |
| | | |
| | | <!-- Inline input row --> |
| | | <div class="tag-input-row"> |
| | | <?php foreach ($field['fields'] as $subfield_name => $subfield_config): ?> |
| | | <?php |
| | | $subfield_config['label'] = $subfield_config['label'] ?? ucfirst($subfield_name); |
| | | $input_name = 'new_' . $subfield_name; |
| | | |
| | | // Store required state but don't render it on the input |
| | | // This prevents form submission validation but allows JS validation |
| | | |
| | | if (array_key_exists('required', $subfield_config)) { |
| | | $subfield_config['data']['required'] = true; |
| | | unset($subfield_config['required']); // Remove required for HTML rendering |
| | | } |
| | | $subfield_config['data']['ignore'] = true; |
| | | |
| | | $this->render($input_name, '', $subfield_config, false, false); |
| | | ?> |
| | | <?php endforeach; ?> |
| | | |
| | | <button type="button" class="button add-tag-item"> |
| | | <?= jvbIcon('plus') ?> <?= $field['add_label'] ?? 'Add' ?> |
| | | </button> |
| | | </div> |
| | | |
| | | <!-- Tags display --> |
| | | <div class="tag-items"> |
| | | <?php foreach ($values as $index => $item_data): ?> |
| | | <?php $this->renderTagItem($field['fields'], $item_data, $index, $name, $tagFormat); ?> |
| | | <?php endforeach; ?> |
| | | </div> |
| | | |
| | | <!-- Template for new tags --> |
| | | <template class="<?=uniqid('tagListItem')?>"> |
| | | <?php $this->renderTagItem($field['fields'], [], '', $name, $tagFormat); ?> |
| | | </template> |
| | | |
| | | <?php if (!empty($field['hint'])): ?> |
| | | <?php $this->renderHint($field['hint']); ?> |
| | | <?php endif; ?> |
| | | |
| | | <?php if (!empty($field['description'])): ?> |
| | | <?php $this->renderDescription($field['description'], $name); ?> |
| | | <?php endif; ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | } |
| | | |
| | | protected function renderGalleryField(string $name, string|null|false $value, array $field):void |
| | | { |
| | | $ids = ($value === '' || is_null($value) || !$value) ? [] : explode(',',$value); |
| | | |
| | | if (!empty($ids)) { |
| | | $ids = array_map('absint', $ids); |
| | | } |
| | | |
| | | $conditional = $this->handleConditionalField($field); |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'].'::'.$name; |
| | | } |
| | | //TODO: This can probably just be a wrapper for renderImageField... |
| | | ?> |
| | | <div class="field gallery <?=$name?>" |
| | | data-field="<?= esc_attr($name); ?>" |
| | | <?= $conditional ?>> |
| | | |
| | | <label><?= esc_html($field['label']); ?></label> |
| | | |
| | | <!-- Container for existing images --> |
| | | <div class="gallery-preview"> |
| | | <?php |
| | | if (!empty($ids)) { |
| | | foreach ($ids as $id) { |
| | | $url = wp_get_attachment_image_url($id, 'medium'); |
| | | if ($url) { |
| | | echo '<div class="preview-item" data-id="' . esc_attr($id) . '">'; |
| | | echo '<img src="' . esc_url($url) . '" alt="">'; |
| | | echo '<button type="button" class="remove-preview">' . jvbIcon('trash', ['title'=>'Remove']) . '</button>'; |
| | | echo '</div>'; |
| | | } |
| | | } |
| | | } |
| | | ?> |
| | | </div> |
| | | |
| | | <!-- Hidden file uploader that will be managed by BatchFileUploader --> |
| | | <div class="file-upload-container"> |
| | | <div class="file-upload-wrapper"> |
| | | <input type="file" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>_temp" |
| | | accept=".jpg,.jpeg,.png,.gif,.webp" |
| | | multiple> |
| | | <p class="file-upload-text"> |
| | | <strong>Click to upload</strong> or drag and drop<br> |
| | | JPG, PNG, GIF, or WEBP (max. 5MB) |
| | | </p> |
| | | </div> |
| | | <div class="file-error"></div> |
| | | </div> |
| | | |
| | | <!-- Hidden input for storing the IDs --> |
| | | <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 if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | </div> |
| | | |
| | | <?php |
| | | } |
| | | private function renderSelectField(string $name, mixed $value, array $field):void |
| | | { |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | $conditional = $this->handleConditionalField($field); |
| | | $default = isset($field['default']) ? $field['default'] : ''; |
| | | $value = !empty($value) ? $value : $default; |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'].'::'.$name; |
| | | } |
| | | ?> |
| | | <div class="field <?=$field['type']?> <?=$name?>" data-field="<?=$name?>" <?=$conditional?>> |
| | | <label for="<?= esc_attr($name); ?>"> |
| | | <?= esc_html($field['label']); ?> |
| | | </label> |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | <select |
| | | id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | <?=$describedBy?> |
| | | <?= !empty($field['required']) ? 'required' : ''; ?> |
| | | > |
| | | <?php foreach ($field['options'] as $key => $label) : ?> |
| | | <option value="<?= esc_attr($key); ?>" |
| | | <?php selected($value, $key); ?>> |
| | | <?= esc_html($label); ?> |
| | | </option> |
| | | <?php endforeach; ?> |
| | | </select> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | protected function renderHtmlField(string $name, mixed $value, array $field):void |
| | | { |
| | | $method_name = $field['content']; |
| | | $content = ''; |
| | | if (method_exists($this, $method_name)) { |
| | | $content = $this->$method_name(); |
| | | } |
| | | |
| | | echo ($content == '') ? '' : sprintf( |
| | | '<div class="html-field-container" data-field-type="html" data-field="%s">%s</div>', |
| | | esc_attr($name), |
| | | $content |
| | | ); |
| | | } |
| | | |
| | | private function renderDateField(string $name, mixed $value, array $field):void |
| | | { |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | $conditional = $this->handleConditionalField($field); |
| | | $format = !empty($field['format']) ? $field['format'] : 'Y-m-d'; |
| | | |
| | | // Format the date if we have a value |
| | | if (!empty($value)) { |
| | | $date = DateTime::createFromFormat($format, $value); |
| | | if ($date) { |
| | | $value = $date->format('Y-m-d'); // HTML date input requires Y-m-d format |
| | | } |
| | | } |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'].'::'.$name; |
| | | } |
| | | ?> |
| | | <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>"> |
| | | <label for="<?= esc_attr($name); ?>"> |
| | | <?= esc_html($field['label']); ?> |
| | | </label> |
| | | <div class="date-wrapper"<?=$describedBy?>> |
| | | <input |
| | | type="date" |
| | | id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | value="<?= esc_attr($value); ?>" |
| | | <?= !empty($field['required']) ? 'required' : ''; ?> |
| | | data-format="<?= esc_attr($format); ?>" |
| | | > |
| | | <?= jvbIcon('calendar') ?> |
| | | </div> |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | public function renderTimeField(string $name, mixed $value, array $field):void |
| | | /** |
| | | * Render individual tag item |
| | | */ |
| | | protected function renderTagItem(array $fields, array $data, int|string $index, string $base_name, string $format): void |
| | | { |
| | | $conditional = $this->handleConditionalField($field); |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | // Convert various time formats to HTML time input format (HH:MM) |
| | | if (!empty($value)) { |
| | | // If it's already in HH:MM format, use as-is |
| | | if (preg_match('/^\d{2}:\d{2}$/', $value)) { |
| | | // Value is already in correct format |
| | | } else { |
| | | // Try to parse and convert |
| | | $timestamp = strtotime($value); |
| | | if ($timestamp !== false) { |
| | | $value = date('H:i', $timestamp); |
| | | } else { |
| | | $value = ''; |
| | | } |
| | | } |
| | | } |
| | | |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'].'::'.$name; |
| | | } |
| | | $tag_text = $this->getTagDisplayText($fields, $data, $format); |
| | | ?> |
| | | <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>"> |
| | | <label for="<?= esc_attr($name); ?>"> |
| | | <?= esc_html($field['label']); ?> |
| | | </label> |
| | | <div class="time-wrapper"<?=$describedBy?>> |
| | | <input |
| | | type="time" |
| | | id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | value="<?= esc_attr($value); ?>" |
| | | <?= !empty($field['required']) ? 'required' : ''; ?> |
| | | <?= !empty($field['min']) ? 'min="' . esc_attr($field['min']) . '"' : ''; ?> |
| | | <?= !empty($field['max']) ? 'max="' . esc_attr($field['max']) . '"' : ''; ?> |
| | | <?= !empty($field['step']) ? 'step="' . esc_attr($field['step']) . '"' : ''; ?> |
| | | > |
| | | <div class="tag-item" data-index="<?= esc_attr($index) ?>"> |
| | | <span class="tag-label"><?= esc_html($tag_text) ?></span> |
| | | |
| | | </div> |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | <!-- Hidden inputs for data --> |
| | | <?php foreach ($fields as $field_name => $field_config): ?> |
| | | <?php |
| | | $value = $data[$field_name] ?? ''; |
| | | $full_name = is_string($index) ? $field_name : "{$base_name}:{$index}:{$field_name}"; |
| | | ?> |
| | | <input type="hidden" |
| | | name="<?= esc_attr($full_name) ?>" |
| | | value="<?= esc_attr($value) ?>" |
| | | data-field="<?= esc_attr($field_name) ?>" |
| | | data-field-type="<?=esc_attr($field_config['type'])?>" /> |
| | | <?php endforeach; ?> |
| | | |
| | | <button type="button" class="remove-tag" aria-label="Remove"> |
| | | <?= jvbIcon('x') ?> |
| | | </button> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | private function renderDatetimeField(string $name, mixed $value, array $field):void |
| | | /** |
| | | * Get tag display text based on format |
| | | */ |
| | | protected function getTagDisplayText(array $fields, array $data, string $format): string |
| | | { |
| | | $conditional = $this->handleConditionalField($field); |
| | | $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : ''; |
| | | // Convert datetime to HTML datetime-local format (YYYY-MM-DDTHH:MM) |
| | | if (!empty($value)) { |
| | | $date = DateTime::createFromFormat('Y-m-d H:i:s', $value); |
| | | if (!$date) { |
| | | // Try alternative formats |
| | | $formats = ['Y-m-d\TH:i:s', 'Y-m-d\TH:i', 'Y-m-d H:i']; |
| | | foreach ($formats as $format) { |
| | | $date = DateTime::createFromFormat($format, $value); |
| | | if ($date) break; |
| | | if (empty($data)) { |
| | | return 'New Item'; |
| | | } |
| | | |
| | | switch ($format) { |
| | | case 'first_field': |
| | | // Use the first field's value |
| | | $first_key = array_key_first($fields); |
| | | return $data[$first_key] ?? 'New Item'; |
| | | |
| | | case 'all_fields': |
| | | // Show all field values separated by commas |
| | | $values = array_filter(array_values($data)); |
| | | return implode(', ', $values) ?: 'New Item'; |
| | | |
| | | case 'custom': |
| | | // Custom format - would need callback |
| | | return 'New Item'; |
| | | |
| | | default: |
| | | // Format is a template string like "{name} ({email})" |
| | | if (strpos($format, '{') !== false) { |
| | | $text = $format; |
| | | foreach ($data as $key => $value) { |
| | | $text = str_replace('{' . $key . '}', $value, $text); |
| | | } |
| | | return $text; |
| | | } |
| | | } |
| | | |
| | | if ($date) { |
| | | $value = $date->format('Y-m-d\TH:i'); // HTML datetime-local format |
| | | } else { |
| | | $value = ''; |
| | | } |
| | | // Use specific field name |
| | | return $data[$format] ?? 'New Item'; |
| | | } |
| | | |
| | | if (array_key_exists('group', $field)) { |
| | | $name = $field['group'].'::'.$name; |
| | | } |
| | | ?> |
| | | <div class="field <?=$field['type']?> <?=$name?>" <?=$conditional?> data-field="<?=$name?>"> |
| | | <label for="<?= esc_attr($name); ?>"> |
| | | <?= esc_html($field['label']); ?> |
| | | </label> |
| | | <div class="datetime-wrapper"<?=$describedBy?>> |
| | | <input |
| | | type="datetime-local" |
| | | id="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>" |
| | | value="<?= esc_attr($value); ?>" |
| | | <?= !empty($field['required']) ? 'required' : ''; ?> |
| | | <?= !empty($field['min']) ? 'min="' . esc_attr($field['min']) . '"' : ''; ?> |
| | | <?= !empty($field['max']) ? 'max="' . esc_attr($field['max']) . '"' : ''; ?> |
| | | <?= !empty($field['step']) ? 'step="' . esc_attr($field['step']) . '"' : ''; ?> |
| | | > |
| | | <?= jvbIcon('calendar') ?> |
| | | </div> |
| | | <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); } ?> |
| | | <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); } ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | |
| | | public function outputCharacterCountJS():void |
| | | { |
| | | ?> |
| | | <script> |
| | | document.querySelectorAll('[maxlength]').forEach(field => { |
| | | const counter = field.closest('.field')?.querySelector('.char-count .current'); |
| | | if (counter) { |
| | | const updateCount = () => counter.textContent = field.value.length; |
| | | field.addEventListener('input', updateCount); |
| | | updateCount(); |
| | | } |
| | | }); |
| | | </script> |
| | | <?php |
| | | } |
| | | |
| | | |
| | | //Conditional Fields |
| | | private function handleConditionalField(array $field):string |
| | | { |
| | | if (empty($field['condition'])) { |
| | | return ''; |
| | | } |
| | | |
| | | $condition = $field['condition']; |
| | | return sprintf( |
| | | 'data-depends-on="%s" data-depends-value="%s" data-depends-operator="%s"', |
| | | esc_attr($field['condition']['field']), |
| | | esc_attr($field['condition']['value']), |
| | | esc_attr($field['condition']['operator'] ?? '==') |
| | | ); |
| | | } |
| | | |
| | | protected function renderDescription(string $description, string $name):void |
| | | { |
| | | $id = $name.'-help'; |
| | | $out = '<div class="has-tooltip"> |
| | | <span class="tt-toggle">'.jvbIcon('question').'</span> |
| | | <div role="tooltip" id="'.$id.'"><p>'.$description.'</p></div> |
| | | </div>'; |
| | | echo $out; |
| | | } |
| | | |
| | | protected function renderHint(array|string $hint):void |
| | | { |
| | | if (is_array($hint)) { |
| | | $out = ''; |
| | | foreach($hint as $h) { |
| | | $out .= '<p class="hint">'.$h.'</p>'; |
| | | } |
| | | } else { |
| | | $out = '<p class="hint">'.$hint.'</p>'; |
| | | } |
| | | echo $out; |
| | | } |
| | | } |
| | |
| | | /** |
| | | * Core meta management class |
| | | */ |
| | | |
| | | /** |
| | | * @deprecated Use Meta() now |
| | | */ |
| | | class MetaManager |
| | | { |
| | | public MetaTypeManager $type_manager; |
| | | public MetaValidator $validator; |
| | | public MetaSanitizer $sanitizer; |
| | | public MetaRenderer $renderer; |
| | | public MetaForm $form; |
| | | public Validator $validator; |
| | | public Sanitizer $sanitizer; |
| | | public Render $renderer; |
| | | protected int|null $object_id; |
| | | public object|null $data; |
| | | protected array $fields =[]; |
| | |
| | | } |
| | | |
| | | $this->type_manager = new MetaTypeManager(); |
| | | $this->validator = new MetaValidator(); |
| | | $this->sanitizer = new MetaSanitizer(); |
| | | $this->renderer = new MetaRenderer(); |
| | | $this->form = new MetaForm(); |
| | | $this->validator = new Validator(); |
| | | $this->sanitizer = new Sanitizer(); |
| | | $this->renderer = new Render(); |
| | | } |
| | | |
| | | /** |
| | |
| | | $out = ''; |
| | | switch ($type) { |
| | | case 'form': |
| | | $out = $this->form->render($name, $value, $config, $showHidden, true); |
| | | $out = Form::render($name, $value, $config); |
| | | $out = apply_filters('jvbRenderFormMeta', $out, $name, $config, $value, $this->getObjectType()); |
| | | break; |
| | | case 'render': |
| | | $out = $this->renderer->render($name, $value, $config, true); |
| | | $out = $this->renderer->render($name, $value, $config); |
| | | if (empty($out) && !$hideEmpty) { |
| | | $out = $this->getEmptyTemplate($config['type'], $name); |
| | | } |
| | |
| | | */ |
| | | class MetaTypeManager |
| | | { |
| | | protected array $type_map = [ |
| | | protected static array $type_map = [ |
| | | 'text' => [ |
| | | 'type' => 'string', |
| | | 'sanitize' => 'sanitize_text_field', |
| | |
| | | 'default' => '', |
| | | ] |
| | | ]; |
| | | public function getType(string $field_name):array |
| | | public static function getType(string $field_name):array |
| | | { |
| | | return $this->type_map[$field_name]??[]; |
| | | return static::$type_map[$field_name]??[]; |
| | | } |
| | | |
| | | public function getMetaType(string $field_type):string |
| | | public static function getMetaType(string $field_type):string |
| | | { |
| | | return $this->type_map[$field_type]['type'] ?? 'string'; |
| | | return static::$type_map[$field_type]['type'] ?? 'string'; |
| | | } |
| | | |
| | | public function getSanitizeCallback(string $field_type):string |
| | | public static function getSanitizeCallback(string $field_type):string |
| | | { |
| | | return $this->type_map[$field_type]['sanitize'] ?? 'sanitize_text_field'; |
| | | return static::$type_map[$field_type]['sanitize'] ?? 'sanitize_text_field'; |
| | | } |
| | | |
| | | public function registerType(string $type, array $config):void |
| | | public static function registerType(string $type, array $config):void |
| | | { |
| | | $this->type_map[$type] = $config; |
| | | static::$type_map[$type] = $config; |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\meta; |
| | | |
| | | use InvalidArgumentException; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | class Registry |
| | | { |
| | | protected string $object; // post type or taxonomy slug |
| | | protected string $object_type; // post, term, user |
| | | protected array $fields; |
| | | protected string $prefix = BASE; |
| | | |
| | | public function __construct(array $fields, string $object, string $object_type) |
| | | { |
| | | if (!in_array($object_type, ['post', 'term', 'user'])) { |
| | | return; |
| | | } |
| | | |
| | | $this->fields = $fields; |
| | | $this->object = jvbCheckBase($object); |
| | | $this->object_type = $object_type; |
| | | } |
| | | |
| | | public function registerMetaFields(): void |
| | | { |
| | | $fields = $this->fields; |
| | | |
| | | foreach ($fields as $name => $options) { |
| | | if (in_array($name, [ |
| | | 'post_title', |
| | | 'post_content', |
| | | 'post_excerpt', |
| | | 'featured_image', |
| | | 'display_name', |
| | | 'user_email', |
| | | ])) { |
| | | unset($fields[$name]); |
| | | } |
| | | } |
| | | |
| | | foreach ($fields as $field_name => $field) { |
| | | $this->validateFieldType($field_name, $field); |
| | | $field = array_merge(MetaTypeManager::getType($field['type']), $field); |
| | | $args = $this->getFieldArgs($field_name, $field); |
| | | |
| | | $args = apply_filters( |
| | | BASE . 'meta_field_args', |
| | | $args, |
| | | $field_name, |
| | | $field, |
| | | $this->object_type |
| | | ); |
| | | |
| | | $temp = register_meta($this->object_type, $this->prefix . $field_name, $args); |
| | | |
| | | if (!$temp) { |
| | | $args['auth_callback'] = gettype($args['auth_callback']); |
| | | error_log('Error with registering meta:' . print_r([ |
| | | 'object_type' => $this->object_type, |
| | | 'prefix' => $this->prefix, |
| | | 'field_name' => $field_name, |
| | | 'args' => $args, |
| | | ], true)); |
| | | } |
| | | |
| | | do_action(BASE . 'meta_field_registered', $field_name, $field, $this); |
| | | } |
| | | } |
| | | |
| | | protected function validateFieldType(string $field_name, array $field): void |
| | | { |
| | | $required = ['name', 'type', 'label']; |
| | | $field['name'] = $field_name; |
| | | foreach ($required as $type) { |
| | | if (!isset($field[$type])) { |
| | | throw new InvalidArgumentException(sprintf('Field %s is required', $type)); |
| | | } |
| | | } |
| | | } |
| | | |
| | | protected function getFieldArgs(string $field_name, array $field): array |
| | | { |
| | | $args = [ |
| | | 'object_subtype' => $this->object, |
| | | 'type' => MetaTypeManager::getMetaType($field['type']), |
| | | 'label' => __($field['label'], 'jvb') ?? '', |
| | | 'description' => __($field['description'] ?? '', 'jvb'), |
| | | 'single' => true, |
| | | 'show_in_rest' => $field['show_in_rest'] ?? true, |
| | | 'sanitize_callback' => $this->getSanitizeCallback($field), |
| | | 'auth_callback' => [$this, 'validate_permissions'], |
| | | 'default' => $field['default'] ?? '', |
| | | ]; |
| | | |
| | | if ($this->object_type === 'post') { |
| | | $args['revisions_enabled'] = true; |
| | | } |
| | | |
| | | if (in_array($field['type'], ['repeater', 'group', 'location']) || $args['type'] === 'array') { |
| | | $args['show_in_rest'] = [ |
| | | 'schema' => $this->getFieldSchema($field) |
| | | ]; |
| | | } |
| | | |
| | | return $args; |
| | | } |
| | | |
| | | /** |
| | | * Build sanitize callback for register_meta |
| | | */ |
| | | protected function getSanitizeCallback(array $field): callable |
| | | { |
| | | return fn($value) => MetaSanitizer::sanitize($value, $field); |
| | | } |
| | | |
| | | protected function getFieldSchema(array $field): array |
| | | { |
| | | if ($field['type'] === 'repeater') { |
| | | $properties = []; |
| | | foreach ($field['fields'] as $key => $subfield) { |
| | | $properties[$key] = [ |
| | | 'type' => MetaTypeManager::getMetaType($subfield['type']) |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'type' => 'object', |
| | | 'items' => [ |
| | | 'type' => 'object', |
| | | 'properties' => $properties |
| | | ] |
| | | ]; |
| | | } elseif ($field['type'] === 'group') { |
| | | $properties = []; |
| | | foreach ($field['fields'] as $key => $subfield) { |
| | | $properties[$key] = [ |
| | | 'type' => MetaTypeManager::getMetaType($subfield['type']) |
| | | ]; |
| | | |
| | | if (isset($subfield['description'])) { |
| | | $properties[$key]['description'] = $subfield['description']; |
| | | } |
| | | |
| | | if (in_array($subfield['type'], ['select', 'radio']) && isset($subfield['options'])) { |
| | | $properties[$key]['enum'] = array_keys($subfield['options']); |
| | | } |
| | | } |
| | | |
| | | return [ |
| | | 'type' => 'object', |
| | | 'properties' => $properties, |
| | | 'additionalProperties' => false |
| | | ]; |
| | | } elseif ($field['type'] === 'location') { |
| | | return [ |
| | | 'type' => 'object', |
| | | 'properties' => [ |
| | | 'address' => ['type' => 'string'], |
| | | 'lat' => ['type' => 'number'], |
| | | 'lng' => ['type' => 'number'] |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'items' => ['type' => 'string'] |
| | | ]; |
| | | } |
| | | |
| | | protected function logError(string $message, array $context = []): void |
| | | { |
| | | error_log(sprintf( |
| | | '[Meta.Registry] %s | Context: %s', |
| | | $message, |
| | | json_encode($context) |
| | | )); |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\meta; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Static utility for rendering meta values on frontend |
| | | * |
| | | * Usage: |
| | | * echo Render::render('price', 150, ['type' => 'number', 'prefix' => '$']); |
| | | * echo Render::render('gallery', $images, ['type' => 'gallery']); |
| | | */ |
| | | class Render |
| | | { |
| | | /** |
| | | * Render a field value based on type |
| | | */ |
| | | public static function render(string $name, mixed $value, array $config = []): string |
| | | { |
| | | if (!apply_filters('jvbShouldRenderMeta', true, $name, $config['type'] ?? 'text', null)) { |
| | | return ''; |
| | | } |
| | | |
| | | $type = $config['type'] ?? 'text'; |
| | | $method = 'render' . str_replace('_', '', ucwords($type, '_')); |
| | | |
| | | $output = method_exists(static::class, $method) |
| | | ? static::$method($name, $value, $config) |
| | | : static::renderDefault($name, $value, $config); |
| | | |
| | | return apply_filters('jvbRenderFrontendMeta', $output, $name, $config, $value, null); |
| | | } |
| | | |
| | | /** |
| | | * Render with Meta instance (convenience method) |
| | | */ |
| | | public static function renderFrom(Meta $meta, string $name, bool $hideEmpty = true): string |
| | | { |
| | | $value = $meta->get($name); |
| | | $config = $meta->config($name) ?? ['type' => 'text']; |
| | | |
| | | $output = static::render($name, $value, $config); |
| | | |
| | | if (empty($output) && !$hideEmpty) { |
| | | return static::getEmptyTemplate($config['type'] ?? 'text', $name); |
| | | } |
| | | |
| | | return $output; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Type Renderers |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | protected static function renderDefault(string $name, mixed $value, array $config): string |
| | | { |
| | | if (empty($value)) { |
| | | return ''; |
| | | } |
| | | return sprintf('<span class="%s">%s</span>', esc_attr($name), esc_html($value)); |
| | | } |
| | | |
| | | protected static function renderText(string $name, mixed $value, array $config): string |
| | | { |
| | | if (empty($value)) { |
| | | return ''; |
| | | } |
| | | return sprintf('<span class="%s">%s</span>', esc_attr($name), esc_html($value)); |
| | | } |
| | | |
| | | protected static function renderTextarea(string $name, mixed $value, array $config): string |
| | | { |
| | | if (empty($value)) { |
| | | return ''; |
| | | } |
| | | return sprintf('<div class="%s">%s</div>', esc_attr($name), wp_kses_post(wpautop($value))); |
| | | } |
| | | |
| | | protected static function renderNumber(string $name, mixed $value, array $config): string |
| | | { |
| | | if ($value === '' || $value === null) { |
| | | return ''; |
| | | } |
| | | |
| | | $prefix = $config['prefix'] ?? ''; |
| | | $suffix = $config['suffix'] ?? ''; |
| | | $decimals = $config['decimals'] ?? 0; |
| | | |
| | | return sprintf( |
| | | '<span class="%s">%s%s%s</span>', |
| | | esc_attr($name), |
| | | esc_html($prefix), |
| | | esc_html(number_format((float)$value, $decimals)), |
| | | esc_html($suffix) |
| | | ); |
| | | } |
| | | |
| | | protected static function renderEmail(string $name, mixed $value, array $config): string |
| | | { |
| | | if (empty($value)) { |
| | | return ''; |
| | | } |
| | | return sprintf( |
| | | '<a class="%s" href="mailto:%s">%s%s</a>', |
| | | esc_attr($name), |
| | | esc_attr($value), |
| | | jvbIcon('envelope'), |
| | | esc_html($value) |
| | | ); |
| | | } |
| | | |
| | | protected static function renderUrl(string $name, mixed $value, array $config): string |
| | | { |
| | | if (empty($value)) { |
| | | return ''; |
| | | } |
| | | |
| | | $label = $config['label'] ?? parse_url($value, PHP_URL_HOST) ?? $value; |
| | | |
| | | return sprintf( |
| | | '<a class="%s" href="%s" target="_blank" rel="noopener">%s%s</a>', |
| | | esc_attr($name), |
| | | esc_url($value), |
| | | jvbIcon('link'), |
| | | esc_html($label) |
| | | ); |
| | | } |
| | | |
| | | protected static function renderDate(string $name, mixed $value, array $config): string |
| | | { |
| | | if (empty($value)) { |
| | | return ''; |
| | | } |
| | | |
| | | $format = $config['format'] ?? get_option('date_format'); |
| | | $timestamp = strtotime($value); |
| | | |
| | | if (!$timestamp) { |
| | | return ''; |
| | | } |
| | | |
| | | return sprintf( |
| | | '<time class="%s" datetime="%s">%s%s</time>', |
| | | esc_attr($name), |
| | | esc_attr(date('Y-m-d', $timestamp)), |
| | | jvbIcon('calendar'), |
| | | esc_html(date_i18n($format, $timestamp)) |
| | | ); |
| | | } |
| | | |
| | | protected static function renderTime(string $name, mixed $value, array $config): string |
| | | { |
| | | if (empty($value)) { |
| | | return ''; |
| | | } |
| | | |
| | | $format = $config['format'] ?? get_option('time_format'); |
| | | $timestamp = strtotime($value); |
| | | |
| | | if (!$timestamp) { |
| | | return ''; |
| | | } |
| | | |
| | | return sprintf( |
| | | '<time class="%s" datetime="%s">%s%s</time>', |
| | | esc_attr($name), |
| | | esc_attr(date('H:i', $timestamp)), |
| | | jvbIcon('clock'), |
| | | esc_html(date_i18n($format, $timestamp)) |
| | | ); |
| | | } |
| | | |
| | | protected static function renderDatetime(string $name, mixed $value, array $config): string |
| | | { |
| | | if (empty($value)) { |
| | | return ''; |
| | | } |
| | | |
| | | $format = $config['format'] ?? get_option('date_format') . ' ' . get_option('time_format'); |
| | | $timestamp = strtotime($value); |
| | | |
| | | if (!$timestamp) { |
| | | return ''; |
| | | } |
| | | |
| | | return sprintf( |
| | | '<time class="%s" datetime="%s">%s%s</time>', |
| | | esc_attr($name), |
| | | esc_attr(date('Y-m-d\TH:i:s', $timestamp)), |
| | | jvbIcon('calendar'), |
| | | esc_html(date_i18n($format, $timestamp)) |
| | | ); |
| | | } |
| | | |
| | | protected static function renderTrueFalse(string $name, mixed $value, array $config): string |
| | | { |
| | | $isTrue = filter_var($value, FILTER_VALIDATE_BOOLEAN); |
| | | $labels = $config['labels'] ?? ['Yes', 'No']; |
| | | |
| | | return sprintf( |
| | | '<span class="%s bool-%s">%s</span>', |
| | | esc_attr($name), |
| | | $isTrue ? 'true' : 'false', |
| | | esc_html($isTrue ? $labels[0] : $labels[1]) |
| | | ); |
| | | } |
| | | |
| | | protected static function renderImage(string $name, mixed $value, array $config): string |
| | | { |
| | | if (empty($value)) { |
| | | return ''; |
| | | } |
| | | |
| | | $size = $config['size'] ?? 'medium'; |
| | | $image = wp_get_attachment_image($value, $size, false, [ |
| | | 'class' => "{$name} attachment-{$size}" |
| | | ]); |
| | | |
| | | return $image ?: ''; |
| | | } |
| | | |
| | | protected static function renderGallery(string $name, mixed $value, array $config): string |
| | | { |
| | | if (empty($value)) { |
| | | return ''; |
| | | } |
| | | |
| | | $ids = is_array($value) ? $value : explode(',', $value); |
| | | $ids = array_filter(array_map('intval', $ids)); |
| | | |
| | | if (empty($ids)) { |
| | | return ''; |
| | | } |
| | | |
| | | $size = $config['size'] ?? 'thumbnail'; |
| | | $output = sprintf('<div class="%s gallery">', esc_attr($name)); |
| | | |
| | | foreach ($ids as $id) { |
| | | $image = wp_get_attachment_image($id, $size); |
| | | if ($image) { |
| | | $output .= $image; |
| | | } |
| | | } |
| | | |
| | | $output .= '</div>'; |
| | | return $output; |
| | | } |
| | | |
| | | protected static function renderTaxonomy(string $name, mixed $value, array $config): string |
| | | { |
| | | if (empty($value)) { |
| | | return ''; |
| | | } |
| | | |
| | | $ids = is_array($value) ? $value : explode(',', $value); |
| | | $ids = array_filter(array_map('intval', $ids)); |
| | | |
| | | if (empty($ids)) { |
| | | return ''; |
| | | } |
| | | |
| | | $terms = []; |
| | | foreach ($ids as $id) { |
| | | $term = get_term($id); |
| | | if ($term && !is_wp_error($term)) { |
| | | $link = get_term_link($term); |
| | | $terms[] = sprintf( |
| | | '<a href="%s">%s</a>', |
| | | esc_url($link), |
| | | esc_html($term->name) |
| | | ); |
| | | } |
| | | } |
| | | |
| | | return sprintf( |
| | | '<span class="%s taxonomy">%s</span>', |
| | | esc_attr($name), |
| | | implode(', ', $terms) |
| | | ); |
| | | } |
| | | |
| | | protected static function renderUser(string $name, mixed $value, array $config): string |
| | | { |
| | | if (empty($value)) { |
| | | return ''; |
| | | } |
| | | |
| | | $ids = is_array($value) ? $value : explode(',', $value); |
| | | $ids = array_filter(array_map('intval', $ids)); |
| | | |
| | | if (empty($ids)) { |
| | | return ''; |
| | | } |
| | | |
| | | $users = []; |
| | | foreach ($ids as $id) { |
| | | $user = get_userdata($id); |
| | | if ($user) { |
| | | $users[] = esc_html($user->display_name); |
| | | } |
| | | } |
| | | |
| | | return sprintf( |
| | | '<span class="%s user">%s</span>', |
| | | esc_attr($name), |
| | | implode(', ', $users) |
| | | ); |
| | | } |
| | | |
| | | protected static function renderSelect(string $name, mixed $value, array $config): string |
| | | { |
| | | if (empty($value) || empty($config['options'])) { |
| | | return ''; |
| | | } |
| | | |
| | | $label = $config['options'][$value] ?? $value; |
| | | |
| | | return sprintf( |
| | | '<span class="%s">%s</span>', |
| | | esc_attr($name), |
| | | esc_html($label) |
| | | ); |
| | | } |
| | | |
| | | protected static function renderSet(string $name, mixed $value, array $config): string |
| | | { |
| | | if (empty($value) || empty($config['options'])) { |
| | | return ''; |
| | | } |
| | | |
| | | $values = is_array($value) ? $value : explode(',', $value); |
| | | $labels = []; |
| | | |
| | | foreach ($values as $v) { |
| | | $v = trim($v); |
| | | if (isset($config['options'][$v])) { |
| | | $labels[] = $config['options'][$v]; |
| | | } |
| | | } |
| | | |
| | | if (empty($labels)) { |
| | | return ''; |
| | | } |
| | | |
| | | return sprintf( |
| | | '<span class="%s">%s</span>', |
| | | esc_attr($name), |
| | | esc_html(implode(', ', $labels)) |
| | | ); |
| | | } |
| | | |
| | | protected static function renderLocation(string $name, mixed $value, array $config): string |
| | | { |
| | | if (empty($value) || empty($value['address'])) { |
| | | return ''; |
| | | } |
| | | |
| | | return sprintf( |
| | | '<address class="%s">%s%s</address>', |
| | | esc_attr($name), |
| | | jvbIcon('map-pin'), |
| | | esc_html($value['address']) |
| | | ); |
| | | } |
| | | |
| | | protected static function renderRepeater(string $name, mixed $value, array $config): string |
| | | { |
| | | if (empty($value) || !is_array($value)) { |
| | | return ''; |
| | | } |
| | | |
| | | $fields = $config['fields'] ?? []; |
| | | $output = sprintf('<div class="%s repeater">', esc_attr($name)); |
| | | |
| | | foreach ($value as $index => $row) { |
| | | $output .= '<div class="repeater-row">'; |
| | | foreach ($row as $fieldName => $fieldValue) { |
| | | $fieldConfig = $fields[$fieldName] ?? ['type' => 'text']; |
| | | $output .= static::render($fieldName, $fieldValue, $fieldConfig); |
| | | } |
| | | $output .= '</div>'; |
| | | } |
| | | |
| | | $output .= '</div>'; |
| | | return $output; |
| | | } |
| | | |
| | | protected static function renderGroup(string $name, mixed $value, array $config): string |
| | | { |
| | | if (empty($value) || !is_array($value)) { |
| | | return ''; |
| | | } |
| | | |
| | | $fields = $config['fields'] ?? []; |
| | | $output = sprintf('<div class="%s group">', esc_attr($name)); |
| | | |
| | | foreach ($value as $fieldName => $fieldValue) { |
| | | $fieldConfig = $fields[$fieldName] ?? ['type' => 'text']; |
| | | $output .= static::render($fieldName, $fieldValue, $fieldConfig); |
| | | } |
| | | |
| | | $output .= '</div>'; |
| | | return $output; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Empty Templates |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | public static function getEmptyTemplate(string $type, string $name): string |
| | | { |
| | | $template = match ($type) { |
| | | 'text', 'textarea', 'number' => '<p class="' . esc_attr($name) . '"></p>', |
| | | 'url', 'email' => '<a class="' . esc_attr($name) . '">' . jvbIcon('link') . '</a>', |
| | | 'set', 'checkbox', 'radio', 'taxonomy', 'user' => '<span class="' . esc_attr($name) . '"></span>', |
| | | 'image', 'gallery' => '<div class="' . esc_attr($name) . ' images"><img/></div>', |
| | | 'date' => '<p class="' . esc_attr($name) . '">' . jvbIcon('calendar') . '<span></span></p>', |
| | | 'time' => '<p class="' . esc_attr($name) . '">' . jvbIcon('clock') . '<time></time></p>', |
| | | 'true_false' => '<p class="' . esc_attr($name) . '"></p>', |
| | | default => '' |
| | | }; |
| | | |
| | | return apply_filters('jvbMetaTypeTemplate', $template, $type); |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\meta; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Fluent accessor for repeater field operations |
| | | * |
| | | * Usage: |
| | | * $meta->repeater('gallery')->field(0, 'image'); |
| | | * $meta->repeater('links')->addRow(['url' => '...', 'title' => '...']); |
| | | * $meta->repeater('gallery')->setField(0, 'caption', 'New caption'); |
| | | */ |
| | | class Repeater |
| | | { |
| | | protected Meta $meta; |
| | | protected string $name; |
| | | protected array $data; |
| | | protected array $config; |
| | | |
| | | public function __construct(Meta $meta, string $name) |
| | | { |
| | | $this->meta = $meta; |
| | | $this->name = $name; |
| | | $this->data = $meta->get($name) ?: []; |
| | | $this->config = $meta->config($name) ?? []; |
| | | |
| | | // Ensure data is array |
| | | if (!is_array($this->data)) { |
| | | $this->data = []; |
| | | } |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Read Operations |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | /** |
| | | * Get all rows |
| | | */ |
| | | public function all(): array |
| | | { |
| | | return $this->data; |
| | | } |
| | | |
| | | /** |
| | | * Get a specific row |
| | | */ |
| | | public function row(int $index): ?array |
| | | { |
| | | return $this->data[$index] ?? null; |
| | | } |
| | | |
| | | /** |
| | | * Get first row |
| | | */ |
| | | public function first(): ?array |
| | | { |
| | | return $this->data[0] ?? null; |
| | | } |
| | | |
| | | /** |
| | | * Get last row |
| | | */ |
| | | public function last(): ?array |
| | | { |
| | | if (empty($this->data)) { |
| | | return null; |
| | | } |
| | | return $this->data[array_key_last($this->data)]; |
| | | } |
| | | |
| | | /** |
| | | * Get field value from specific row |
| | | */ |
| | | public function field(int $index, string $field): mixed |
| | | { |
| | | return $this->data[$index][$field] ?? null; |
| | | } |
| | | |
| | | /** |
| | | * Get row count |
| | | */ |
| | | public function count(): int |
| | | { |
| | | return count($this->data); |
| | | } |
| | | |
| | | /** |
| | | * Check if empty |
| | | */ |
| | | public function isEmpty(): bool |
| | | { |
| | | return empty($this->data); |
| | | } |
| | | |
| | | /** |
| | | * Check if row exists |
| | | */ |
| | | public function hasRow(int $index): bool |
| | | { |
| | | return isset($this->data[$index]); |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Write Operations |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | /** |
| | | * Set field value in specific row |
| | | */ |
| | | public function setField(int $index, string $field, mixed $value): self |
| | | { |
| | | if (!isset($this->data[$index])) { |
| | | $this->data[$index] = []; |
| | | } |
| | | |
| | | $this->data[$index][$field] = $value; |
| | | $this->sync(); |
| | | |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Add a new row |
| | | */ |
| | | public function addRow(array $data = []): self |
| | | { |
| | | $this->data[] = $data; |
| | | $this->sync(); |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Insert row at specific index |
| | | */ |
| | | public function insertRow(int $index, array $data = []): self |
| | | { |
| | | array_splice($this->data, $index, 0, [$data]); |
| | | $this->sync(); |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Update entire row (merge with existing) |
| | | */ |
| | | public function updateRow(int $index, array $data): self |
| | | { |
| | | $this->data[$index] = array_merge($this->data[$index] ?? [], $data); |
| | | $this->sync(); |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Replace entire row |
| | | */ |
| | | public function replaceRow(int $index, array $data): self |
| | | { |
| | | $this->data[$index] = $data; |
| | | $this->sync(); |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Remove a row |
| | | */ |
| | | public function removeRow(int $index): self |
| | | { |
| | | unset($this->data[$index]); |
| | | $this->data = array_values($this->data); // Re-index |
| | | $this->sync(); |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Remove last row |
| | | */ |
| | | public function pop(): self |
| | | { |
| | | array_pop($this->data); |
| | | $this->sync(); |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Remove first row |
| | | */ |
| | | public function shift(): self |
| | | { |
| | | array_shift($this->data); |
| | | $this->sync(); |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Clear all rows |
| | | */ |
| | | public function clear(): self |
| | | { |
| | | $this->data = []; |
| | | $this->sync(); |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Reorder rows |
| | | */ |
| | | public function reorder(array $newOrder): self |
| | | { |
| | | $reordered = []; |
| | | foreach ($newOrder as $index) { |
| | | if (isset($this->data[$index])) { |
| | | $reordered[] = $this->data[$index]; |
| | | } |
| | | } |
| | | $this->data = $reordered; |
| | | $this->sync(); |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Move row to new position |
| | | */ |
| | | public function moveRow(int $from, int $to): self |
| | | { |
| | | if (!isset($this->data[$from])) { |
| | | return $this; |
| | | } |
| | | |
| | | $row = $this->data[$from]; |
| | | unset($this->data[$from]); |
| | | $this->data = array_values($this->data); |
| | | array_splice($this->data, $to, 0, [$row]); |
| | | $this->sync(); |
| | | |
| | | return $this; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Query Operations |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | /** |
| | | * Find rows where field equals value |
| | | */ |
| | | public function where(string $field, mixed $value): array |
| | | { |
| | | return array_filter($this->data, fn($row) => ($row[$field] ?? null) === $value); |
| | | } |
| | | |
| | | /** |
| | | * Find first row where field equals value |
| | | */ |
| | | public function firstWhere(string $field, mixed $value): ?array |
| | | { |
| | | foreach ($this->data as $row) { |
| | | if (($row[$field] ?? null) === $value) { |
| | | return $row; |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Find row index where field equals value |
| | | */ |
| | | public function findIndex(string $field, mixed $value): ?int |
| | | { |
| | | foreach ($this->data as $index => $row) { |
| | | if (($row[$field] ?? null) === $value) { |
| | | return $index; |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Filter rows by callback |
| | | */ |
| | | public function filter(callable $callback): array |
| | | { |
| | | return array_filter($this->data, $callback); |
| | | } |
| | | |
| | | /** |
| | | * Map rows through callback |
| | | */ |
| | | public function map(callable $callback): array |
| | | { |
| | | return array_map($callback, $this->data); |
| | | } |
| | | |
| | | /** |
| | | * Pluck single field from all rows |
| | | */ |
| | | public function pluck(string $field): array |
| | | { |
| | | return array_column($this->data, $field); |
| | | } |
| | | |
| | | /** |
| | | * Sort rows by field |
| | | */ |
| | | public function sortBy(string $field, string $direction = 'asc'): self |
| | | { |
| | | usort($this->data, function($a, $b) use ($field, $direction) { |
| | | $aVal = $a[$field] ?? null; |
| | | $bVal = $b[$field] ?? null; |
| | | |
| | | $result = $aVal <=> $bVal; |
| | | return $direction === 'desc' ? -$result : $result; |
| | | }); |
| | | |
| | | $this->sync(); |
| | | return $this; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Bulk Operations |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | /** |
| | | * Replace all rows |
| | | */ |
| | | public function setAll(array $rows): self |
| | | { |
| | | $this->data = $rows; |
| | | $this->sync(); |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Append multiple rows |
| | | */ |
| | | public function addRows(array $rows): self |
| | | { |
| | | foreach ($rows as $row) { |
| | | $this->data[] = $row; |
| | | } |
| | | $this->sync(); |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Update field in all rows |
| | | */ |
| | | public function setFieldAll(string $field, mixed $value): self |
| | | { |
| | | foreach ($this->data as $index => $row) { |
| | | $this->data[$index][$field] = $value; |
| | | } |
| | | $this->sync(); |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Remove field from all rows |
| | | */ |
| | | public function removeFieldAll(string $field): self |
| | | { |
| | | foreach ($this->data as $index => $row) { |
| | | unset($this->data[$index][$field]); |
| | | } |
| | | $this->sync(); |
| | | return $this; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Internal |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | /** |
| | | * Sync data back to Meta instance |
| | | */ |
| | | protected function sync(): void |
| | | { |
| | | $this->meta->set($this->name, $this->data); |
| | | } |
| | | |
| | | /** |
| | | * Get field config for subfield |
| | | */ |
| | | public function fieldConfig(string $field): ?array |
| | | { |
| | | return $this->config['fields'][$field] ?? null; |
| | | } |
| | | |
| | | /** |
| | | * Get row label field name |
| | | */ |
| | | public function rowLabelField(): ?string |
| | | { |
| | | return $this->config['row_label'] ?? null; |
| | | } |
| | | } |
| File was renamed from inc/meta/MetaSanitizer.php |
| | |
| | | <?php |
| | | namespace JVBase\meta; |
| | | |
| | | use JVBase\meta\MetaTypeManager; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; // Exit if accessed directly |
| | | } |
| | |
| | | /** |
| | | * Handles meta value sanitization |
| | | */ |
| | | class MetaSanitizer |
| | | class Sanitizer |
| | | { |
| | | protected MetaTypeManager $type_manager; |
| | | |
| | | public function __construct() |
| | | { |
| | | $this->type_manager = new MetaTypeManager(); |
| | | } |
| | | public static function sanitize(mixed $value, array $field_config): mixed |
| | | { |
| | | $callback = static::getCallback($field_config); |
| | | |
| | | public function sanitize(mixed $value, array $field_config):mixed |
| | | { |
| | | $callback = $this->getCallback($field_config); |
| | | if (is_array($callback)) { |
| | | return call_user_func([$this, $callback[1]], $value, $field_config); |
| | | } |
| | | if (method_exists($this, $callback)) { |
| | | return $this->$callback($value, $field_config); |
| | | } else { |
| | | return call_user_func($callback, $value); |
| | | } |
| | | } |
| | | if (is_array($callback)) { |
| | | return call_user_func([static::class, $callback[1]], $value, $field_config); |
| | | } |
| | | if (method_exists(static::class, $callback)) { |
| | | return static::$callback($value, $field_config); |
| | | } |
| | | |
| | | public function getCallback(array $field_config):mixed |
| | | return call_user_func($callback, $value); |
| | | } |
| | | |
| | | public static function getCallback(array $field_config):mixed |
| | | { |
| | | return $field_config['sanitize'] ?? |
| | | $this->type_manager->getSanitizeCallback($field_config['type']); |
| | | MetaTypeManager::getSanitizeCallback($field_config['type']); |
| | | } |
| | | |
| | | protected function sanitizeTaxonomy(array|string $values, array $field_config):string |
| | | protected static function sanitizeTaxonomy(array|string $values, array $field_config):string |
| | | { |
| | | if (!is_array($values)) { |
| | | $values = explode(',', $values); |
| | |
| | | return implode(',', $values); |
| | | } |
| | | |
| | | protected function sanitizeUser(array|string $values, array $field_config):string |
| | | protected static function sanitizeUser(array|string $values, array $field_config):string |
| | | { |
| | | if (!is_array($values)) { |
| | | $values = explode(',', $values); |
| | |
| | | return implode(',', $values); |
| | | } |
| | | |
| | | protected function sanitizeTagList(array $values, array $field_config): array |
| | | protected static function sanitizeTagList(array $values, array $field_config): array |
| | | { |
| | | if (!is_array($values)) { |
| | | return []; |
| | | } |
| | | |
| | | if (empty(array_filter($values, fn($value) => !empty($value)))) { |
| | | return []; |
| | | } |
| | |
| | | } |
| | | |
| | | $subfield_config['name'] = $key; // For backwards compatibility |
| | | $clean_row[$key] = $this->sanitize($row[$key], $subfield_config); |
| | | $clean_row[$key] = static::sanitize($row[$key], $subfield_config); |
| | | } |
| | | |
| | | // Only add row if it has at least one non-empty value |
| | |
| | | return $sanitized; |
| | | } |
| | | |
| | | protected function sanitizeRepeater(array $values, array $field_config):array |
| | | protected static function sanitizeRepeater(array $values, array $field_config):array |
| | | { |
| | | if (!is_array($values)) { |
| | | return []; |
| | | } |
| | | if (empty(array_filter($values, fn($value) => !empty($value)))) { |
| | | return []; |
| | | } |
| | |
| | | continue; |
| | | } |
| | | $subfield_config['name'] = $key;//For backwards compatability |
| | | $clean_row[$key] = $this->sanitize($row[$key], $subfield_config); |
| | | $clean_row[$key] = static::sanitize($row[$key], $subfield_config); |
| | | } |
| | | $sanitized[] = $clean_row; |
| | | } |
| | |
| | | return $sanitized; |
| | | } |
| | | |
| | | protected function sanitizeGroup(array|string $values, array $field_config):array |
| | | protected static function sanitizeGroup(array|string $values, array $field_config):array |
| | | { |
| | | if (!is_array($values)) { |
| | | return []; |
| | |
| | | foreach ($field_config['fields'] as $key => $subfield_config) { |
| | | if (!array_key_exists($key, $clean_values)) { |
| | | // Use default value if not provided |
| | | $default = $this->type_manager->getType($subfield_config['type'])['default'] ?? ''; |
| | | $default = MetaTypeManager::getType($subfield_config['type'])['default'] ?? ''; |
| | | $sanitized[$key] = $default; |
| | | continue; |
| | | } |
| | | |
| | | $subfield_config['name'] = $key; // For backwards compatibility |
| | | $sanitized[$key] = $this->sanitize($clean_values[$key], $subfield_config); |
| | | $sanitized[$key] = static::sanitize($clean_values[$key], $subfield_config); |
| | | } |
| | | |
| | | return $sanitized; |
| | | } |
| | | |
| | | protected function sanitizeUpload(array|string $value):string |
| | | protected static function sanitizeUpload(array|string $value):string |
| | | { |
| | | if (empty($value)) { |
| | | return ''; |
| | |
| | | return implode(',', $valid_ids); |
| | | } |
| | | |
| | | protected function sanitizeLocation(array $value, array $field_config):array |
| | | protected static function sanitizeLocation(array $value, array $field_config):array |
| | | { |
| | | error_log('Location field to sanitize: '.print_r($value, true)); |
| | | return [ |
| | |
| | | ]; |
| | | } |
| | | |
| | | protected function sanitizeOptions(array|string $value, array $field_config):string |
| | | protected static function sanitizeOptions(array|string $value, array $field_config):string |
| | | { |
| | | error_log('Sanitizing options: '.print_r($value, true)); |
| | | if (!isset($field_config['options'])) { |
| | |
| | | return implode(',', array_intersect($value, array_keys($field_config['options']))); |
| | | } |
| | | |
| | | protected function sanitizeDate(string $value, array $field_config):string |
| | | protected static function sanitizeDate(string $value, array $field_config):string |
| | | { |
| | | $timestamp = strtotime($value); |
| | | return $timestamp ? date('Y-m-d', $timestamp) : ''; |
| | | } |
| | | |
| | | protected function sanitizeDateTime(string $value, array $field_config): string |
| | | protected static function sanitizeDateTime(string $value, array $field_config): string |
| | | { |
| | | if (empty($value)) { |
| | | return ''; |
| | |
| | | return date('Y-m-d H:i:s', $timestamp); |
| | | } |
| | | |
| | | protected function sanitizeTime(string $value, array $field_config):string |
| | | protected static function sanitizeTime(string $value, array $field_config):string |
| | | { |
| | | // Remove any whitespace |
| | | $value = trim($value); |
| | |
| | | return ''; |
| | | } |
| | | |
| | | public function sanitizeFloat(string $value, array $config):float |
| | | public static function sanitizeFloat(string $value, array $config):float |
| | | { |
| | | if (is_numeric($value)) { |
| | | return (float) $value; |
| | |
| | | namespace JVBase\meta; |
| | | |
| | | use Exception; |
| | | use wpdb; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | |
| | | */ |
| | | class Storage |
| | | { |
| | | protected wpdb $wpdb; |
| | | protected \wpdb $wpdb; |
| | | |
| | | public function __construct() |
| | | { |
| | |
| | | $this->wpdb = $wpdb; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Single Item Operations |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | /** |
| | | * Load a single field value from database |
| | | */ |
| | |
| | | } |
| | | |
| | | /** |
| | | * Load multiple field values in a single query |
| | | * Load multiple field values for single item |
| | | */ |
| | | public function getAll(Item $item, array $fieldNames): array |
| | | { |
| | | if (empty($fieldNames) || !$item->id) { |
| | | if (empty($fieldNames) || (!$item->id && $item->objectType !== 'options')) { |
| | | return []; |
| | | } |
| | | |
| | |
| | | |
| | | $values = []; |
| | | |
| | | // Get meta fields in bulk |
| | | // Get meta fields in bulk query |
| | | if (!empty($metaFields)) { |
| | | $values = $this->bulkGetMeta($item, $metaFields); |
| | | } |
| | |
| | | } |
| | | |
| | | /** |
| | | * Save all dirty fields on an item |
| | | * Save all dirty fields on a single item |
| | | */ |
| | | public function save(Item $item, bool $updateTimestamp = true): bool |
| | | { |
| | |
| | | */ |
| | | public function delete(Item $item, string $name): bool |
| | | { |
| | | // Handle taxonomy fields |
| | | $config = $item->getFieldConfig($name); |
| | | if ($config && ($config['type'] ?? '') === 'taxonomy' && !isset($config['taxonomy_type'])) { |
| | | $taxonomy = jvbCheckBase($config['taxonomy']); |
| | | wp_set_object_terms($item->id, [], $taxonomy, false); |
| | | return true; |
| | | } |
| | | |
| | | $metaKey = BASE . $name; |
| | | |
| | | return match ($item->objectType) { |
| | |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Protected helpers |
| | | // Bulk Operations |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | /** |
| | | * Save multiple Meta instances in optimized transaction |
| | | * @param Meta[] $metas Array of Meta instances |
| | | * @return array<int, bool> Results keyed by item ID |
| | | */ |
| | | public static function saveBulk(array $metas, bool $updateTimestamp = true): array |
| | | { |
| | | global $wpdb; |
| | | |
| | | $results = []; |
| | | $postIdsToUpdate = []; |
| | | |
| | | $wpdb->query('START TRANSACTION'); |
| | | |
| | | try { |
| | | // Group by object type for efficient processing |
| | | $grouped = []; |
| | | foreach ($metas as $meta) { |
| | | $item = $meta->item(); |
| | | $type = $item->objectType; |
| | | $grouped[$type][] = ['meta' => $meta, 'item' => $item]; |
| | | } |
| | | |
| | | foreach ($grouped as $objectType => $group) { |
| | | $storage = new self(); |
| | | [$table, $idColumn] = $storage->getTableInfo($objectType); |
| | | |
| | | if (!$table && $objectType !== 'options') { |
| | | continue; |
| | | } |
| | | |
| | | // Collect all operations |
| | | $metaInserts = []; |
| | | $wpDefaultUpdates = []; |
| | | $taxonomyUpdates = []; |
| | | $optionUpdates = []; |
| | | |
| | | foreach ($group as $entry) { |
| | | $item = $entry['item']; |
| | | $dirty = $item->getDirtyFields(); |
| | | |
| | | if (empty($dirty)) { |
| | | $results[$item->id] = true; |
| | | continue; |
| | | } |
| | | |
| | | foreach ($dirty as $field) { |
| | | if ($objectType === 'options') { |
| | | $optionUpdates[] = [ |
| | | 'key' => $storage->optionKey($item, $field->name), |
| | | 'value' => $field->value |
| | | ]; |
| | | } elseif ($field->isWpDefault()) { |
| | | $wpDefaultUpdates[$item->id][$field->name] = $field->value; |
| | | } elseif ($field->isTaxonomy()) { |
| | | $taxonomyUpdates[] = [ |
| | | 'object_id' => $item->id, |
| | | 'taxonomy' => jvbCheckBase($field->config['taxonomy']), |
| | | 'value' => $field->value |
| | | ]; |
| | | } else { |
| | | $metaInserts[] = [ |
| | | 'id' => $item->id, |
| | | 'key' => BASE . $field->name, |
| | | 'value' => maybe_serialize($field->value) |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | if ($updateTimestamp && $objectType === 'post') { |
| | | $postIdsToUpdate[] = $item->id; |
| | | } |
| | | } |
| | | |
| | | // Execute bulk operations |
| | | if (!empty($metaInserts)) { |
| | | self::bulkUpsertMeta($table, $idColumn, $metaInserts); |
| | | } |
| | | |
| | | if (!empty($wpDefaultUpdates)) { |
| | | self::batchUpdateWpDefaults($objectType, $wpDefaultUpdates); |
| | | } |
| | | |
| | | if (!empty($taxonomyUpdates)) { |
| | | self::batchUpdateTaxonomies($taxonomyUpdates); |
| | | } |
| | | |
| | | if (!empty($optionUpdates)) { |
| | | self::batchUpdateOptions($optionUpdates); |
| | | } |
| | | |
| | | // Mark all fields clean |
| | | foreach ($group as $entry) { |
| | | $entry['item']->markAllClean(); |
| | | $results[$entry['item']->id ?? 'options'] = true; |
| | | } |
| | | } |
| | | |
| | | $wpdb->query('COMMIT'); |
| | | |
| | | // Update post timestamps in single query |
| | | if (!empty($postIdsToUpdate)) { |
| | | self::batchTouchPosts(array_unique($postIdsToUpdate)); |
| | | } |
| | | |
| | | // Clear caches |
| | | foreach ($metas as $meta) { |
| | | (new self())->clearCache($meta->item()); |
| | | } |
| | | |
| | | return $results; |
| | | |
| | | } catch (Exception $e) { |
| | | $wpdb->query('ROLLBACK'); |
| | | |
| | | foreach ($metas as $meta) { |
| | | $results[$meta->item()->id ?? 'options'] = false; |
| | | } |
| | | |
| | | JVB()->error()->log('meta_storage', 'Bulk save failed: ' . $e->getMessage(), [], 'error'); |
| | | |
| | | return $results; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Bulk load meta for multiple items |
| | | * @param array $ids Object IDs |
| | | * @param string $objectType post, term, user |
| | | * @param array $fields Field names to load |
| | | * @return array<int, array<string, mixed>> Values keyed by ID then field name |
| | | */ |
| | | public static function getBulkValues(array $ids, string $objectType, array $fields): array |
| | | { |
| | | if (empty($ids) || empty($fields)) { |
| | | return []; |
| | | } |
| | | |
| | | global $wpdb; |
| | | $storage = new self(); |
| | | |
| | | [$table, $idColumn] = $storage->getTableInfo($objectType); |
| | | |
| | | if (!$table) { |
| | | return []; |
| | | } |
| | | |
| | | // Separate WP defaults from custom meta |
| | | $defaults = Item::WP_DEFAULTS[$objectType] ?? []; |
| | | $wpFields = array_intersect($defaults, $fields); |
| | | $metaFields = array_diff($fields, $wpFields); |
| | | |
| | | // Initialize results |
| | | $values = []; |
| | | foreach ($ids as $id) { |
| | | $values[$id] = array_fill_keys($fields, ''); |
| | | } |
| | | |
| | | // Bulk get custom meta |
| | | if (!empty($metaFields)) { |
| | | $metaKeys = array_map(fn($f) => BASE . $f, $metaFields); |
| | | |
| | | $idPlaceholders = implode(',', array_fill(0, count($ids), '%d')); |
| | | $keyPlaceholders = implode(',', array_fill(0, count($metaKeys), '%s')); |
| | | |
| | | $query = $wpdb->prepare( |
| | | "SELECT {$idColumn} as object_id, meta_key, meta_value |
| | | FROM {$table} |
| | | WHERE {$idColumn} IN ({$idPlaceholders}) |
| | | AND meta_key IN ({$keyPlaceholders})", |
| | | array_merge($ids, $metaKeys) |
| | | ); |
| | | |
| | | $results = $wpdb->get_results($query, ARRAY_A); |
| | | |
| | | foreach ($results as $row) { |
| | | $objectId = (int)$row['object_id']; |
| | | $fieldName = str_replace(BASE, '', $row['meta_key']); |
| | | $values[$objectId][$fieldName] = maybe_unserialize($row['meta_value']); |
| | | } |
| | | } |
| | | |
| | | // Get WP default fields (requires individual lookups unfortunately) |
| | | if (!empty($wpFields)) { |
| | | foreach ($ids as $id) { |
| | | $tempItem = new Item($id, $objectType); |
| | | |
| | | // Load WP object for defaults |
| | | $tempItem->wpObject = match ($objectType) { |
| | | 'post' => get_post($id), |
| | | 'term' => get_term($id), |
| | | 'user' => get_user_by('id', $id), |
| | | default => null |
| | | }; |
| | | |
| | | foreach ($wpFields as $field) { |
| | | $values[$id][$field] = $storage->getWpDefault($tempItem, $field); |
| | | } |
| | | } |
| | | } |
| | | |
| | | return $values; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Protected Helpers - Single Item |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | protected function getWpDefault(Item $item, string $name): mixed |
| | |
| | | $taxonomy = jvbCheckBase($field->config['taxonomy']); |
| | | $value = $field->value; |
| | | |
| | | if (empty(trim($value))) { |
| | | if (empty(trim((string)$value))) { |
| | | wp_set_object_terms($item->id, [], $taxonomy, false); |
| | | return true; |
| | | } |
| | |
| | | return $values; |
| | | } |
| | | |
| | | protected function getTableInfo(string $objectType): array |
| | | public function getTableInfo(string $objectType): array |
| | | { |
| | | return match ($objectType) { |
| | | 'post' => [$this->wpdb->postmeta, 'post_id'], |
| | |
| | | return update_option($this->optionKey($item, $field->name), $field->value); |
| | | } |
| | | |
| | | protected function optionKey(Item $item, string $name): string |
| | | public function optionKey(Item $item, string $name): string |
| | | { |
| | | return $item->baseKey |
| | | ? BASE . $item->baseKey . '_' . $name |
| | | : BASE . $name; |
| | | } |
| | | |
| | | protected function clearCache(Item $item): void |
| | | public function clearCache(Item $item): void |
| | | { |
| | | match ($item->objectType) { |
| | | 'post' => clean_post_cache($item->id), |
| | |
| | | default => null |
| | | }; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Protected Helpers - Bulk Operations |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | /** |
| | | * Bulk upsert meta using INSERT ... ON DUPLICATE KEY UPDATE |
| | | */ |
| | | protected static function bulkUpsertMeta(string $table, string $idColumn, array $inserts): void |
| | | { |
| | | global $wpdb; |
| | | |
| | | if (empty($inserts)) { |
| | | return; |
| | | } |
| | | |
| | | // MySQL's ON DUPLICATE KEY requires a unique index |
| | | // For meta tables, we need to check existing and do update/insert |
| | | $existing = []; |
| | | $toInsert = []; |
| | | $toUpdate = []; |
| | | |
| | | // Check which meta keys exist |
| | | $checks = []; |
| | | foreach ($inserts as $row) { |
| | | $checks[] = $wpdb->prepare("(%d, %s)", $row['id'], $row['key']); |
| | | } |
| | | |
| | | $existingQuery = "SELECT {$idColumn}, meta_key FROM {$table} |
| | | WHERE ({$idColumn}, meta_key) IN (" . implode(',', $checks) . ")"; |
| | | $existingRows = $wpdb->get_results($existingQuery, ARRAY_A); |
| | | |
| | | foreach ($existingRows as $row) { |
| | | $existing[$row[$idColumn] . '_' . $row['meta_key']] = true; |
| | | } |
| | | |
| | | // Separate inserts and updates |
| | | foreach ($inserts as $row) { |
| | | $key = $row['id'] . '_' . $row['key']; |
| | | if (isset($existing[$key])) { |
| | | $toUpdate[] = $row; |
| | | } else { |
| | | $toInsert[] = $row; |
| | | } |
| | | } |
| | | |
| | | // Batch insert new records |
| | | if (!empty($toInsert)) { |
| | | $values = []; |
| | | $placeholders = []; |
| | | |
| | | foreach ($toInsert as $row) { |
| | | $placeholders[] = "(%d, %s, %s)"; |
| | | $values[] = $row['id']; |
| | | $values[] = $row['key']; |
| | | $values[] = $row['value']; |
| | | } |
| | | |
| | | $sql = "INSERT INTO {$table} ({$idColumn}, meta_key, meta_value) VALUES " . implode(', ', $placeholders); |
| | | $wpdb->query($wpdb->prepare($sql, $values)); |
| | | } |
| | | |
| | | // Batch update existing records |
| | | if (!empty($toUpdate)) { |
| | | foreach ($toUpdate as $row) { |
| | | $wpdb->update( |
| | | $table, |
| | | ['meta_value' => $row['value']], |
| | | [$idColumn => $row['id'], 'meta_key' => $row['key']], |
| | | ['%s'], |
| | | ['%d', '%s'] |
| | | ); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Batch update post timestamps |
| | | */ |
| | | protected static function batchTouchPosts(array $postIds): void |
| | | { |
| | | global $wpdb; |
| | | |
| | | if (empty($postIds)) { |
| | | return; |
| | | } |
| | | |
| | | $now = current_time('mysql'); |
| | | $nowGmt = current_time('mysql', true); |
| | | $ids = implode(',', array_map('intval', $postIds)); |
| | | |
| | | $wpdb->query( |
| | | "UPDATE {$wpdb->posts} |
| | | SET post_modified = '{$now}', post_modified_gmt = '{$nowGmt}' |
| | | WHERE ID IN ({$ids})" |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Batch update taxonomy relationships |
| | | */ |
| | | protected static function batchUpdateTaxonomies(array $updates): void |
| | | { |
| | | foreach ($updates as $update) { |
| | | $termIds = empty(trim((string)$update['value'])) |
| | | ? [] |
| | | : array_map('intval', array_filter(explode(',', $update['value']))); |
| | | |
| | | wp_set_object_terms($update['object_id'], $termIds, $update['taxonomy'], false); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Batch update WordPress default fields |
| | | */ |
| | | protected static function batchUpdateWpDefaults(string $objectType, array $updates): void |
| | | { |
| | | foreach ($updates as $id => $fields) { |
| | | // Handle post_thumbnail separately |
| | | if (isset($fields['post_thumbnail'])) { |
| | | set_post_thumbnail($id, $fields['post_thumbnail']); |
| | | unset($fields['post_thumbnail']); |
| | | } |
| | | if (isset($fields['featured_image'])) { |
| | | set_post_thumbnail($id, $fields['featured_image']); |
| | | unset($fields['featured_image']); |
| | | } |
| | | |
| | | if (empty($fields)) { |
| | | continue; |
| | | } |
| | | |
| | | // Handle post_date conversion |
| | | if (isset($fields['post_date'])) { |
| | | $datetime = strtotime($fields['post_date']); |
| | | if ($datetime !== false) { |
| | | $fields['post_date'] = date('Y-m-d H:i:s', $datetime); |
| | | $fields['post_date_gmt'] = get_gmt_from_date($fields['post_date']); |
| | | $fields['edit_date'] = true; |
| | | } |
| | | } |
| | | |
| | | match ($objectType) { |
| | | 'post' => wp_update_post(array_merge(['ID' => $id], $fields)), |
| | | 'user' => wp_update_user(array_merge(['ID' => $id], $fields)), |
| | | 'term' => null, // Terms need taxonomy, handled separately |
| | | default => null |
| | | }; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Batch update options |
| | | */ |
| | | protected static function batchUpdateOptions(array $updates): void |
| | | { |
| | | foreach ($updates as $update) { |
| | | update_option($update['key'], $update['value']); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\meta; |
| | | |
| | | use DateTime; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; // Exit if accessed directly |
| | | } |
| | | /** |
| | | * Handles meta value validation |
| | | */ |
| | | class Validator |
| | | { |
| | | protected array $errors = []; |
| | | |
| | | public static function validate(mixed $value, array $config): bool|array |
| | | { |
| | | $errors = []; |
| | | $type = $config['type']; |
| | | $name = $config['name'] ?? 'field'; |
| | | |
| | | // Required field check |
| | | if (!empty($config['required']) && static::isEmpty($value)) { |
| | | return [$name => __('This field is required', 'jvb')]; |
| | | } |
| | | |
| | | // Skip validation for empty optional fields |
| | | if (static::isEmpty($value)) { |
| | | return true; |
| | | } |
| | | |
| | | // Type-specific validation |
| | | $method = 'validate' . str_replace('_', '', ucwords($type, '_')); |
| | | if (method_exists(static::class, $method)) { |
| | | $result = static::$method($value, $config); |
| | | if ($result !== true) { |
| | | return is_array($result) ? $result : [$name => $result]; |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | protected static function isEmpty(mixed $value): bool |
| | | { |
| | | if ($value === null || $value === '' || $value === []) { |
| | | return true; |
| | | } |
| | | if (is_array($value) && empty(array_filter($value))) { |
| | | return true; |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | protected static function validateText(string $value, array $config): bool|string |
| | | { |
| | | if (isset($config['limit']) && strlen($value) > $config['limit']) { |
| | | return sprintf(__('Must not exceed %d characters', 'jvb'), $config['limit']); |
| | | } |
| | | |
| | | if (isset($config['pattern']) && !preg_match($config['pattern'], $value)) { |
| | | return __('Invalid format', 'jvb'); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | protected static function validateTextarea(string $value, array $config): bool|string |
| | | { |
| | | return static::validateText($value, $config); |
| | | } |
| | | |
| | | protected static function validateNumber(mixed $value, array $config): bool|string |
| | | { |
| | | if (!is_numeric($value)) { |
| | | return __('Must be a number', 'jvb'); |
| | | } |
| | | |
| | | if (isset($config['min']) && $value < $config['min']) { |
| | | return sprintf(__('Must be at least %s', 'jvb'), $config['min']); |
| | | } |
| | | |
| | | if (isset($config['max']) && $value > $config['max']) { |
| | | return sprintf(__('Must not exceed %s', 'jvb'), $config['max']); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | protected static function validateEmail(string $value, array $config): bool|string |
| | | { |
| | | if (!is_email($value)) { |
| | | return __('Invalid email address', 'jvb'); |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | protected static function validateUrl(string $value, array $config): bool|string |
| | | { |
| | | if (filter_var($value, FILTER_VALIDATE_URL) === false) { |
| | | return __('Must be a valid URL', 'jvb'); |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | protected static function validateDate(string $value, array $config): bool|string |
| | | { |
| | | $timestamp = strtotime($value); |
| | | if ($timestamp === false) { |
| | | return __('Invalid date format', 'jvb'); |
| | | } |
| | | |
| | | if (isset($config['min_date']) && $timestamp < strtotime($config['min_date'])) { |
| | | return sprintf(__('Date must be after %s', 'jvb'), $config['min_date']); |
| | | } |
| | | |
| | | if (isset($config['max_date']) && $timestamp > strtotime($config['max_date'])) { |
| | | return sprintf(__('Date must be before %s', 'jvb'), $config['max_date']); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | |
| | | protected static function validateDatetime(string $value, array $config): bool|string |
| | | { |
| | | $date = DateTime::createFromFormat('Y-m-d H:i:s', $value); |
| | | if (!$date) { |
| | | $formats = ['Y-m-d\TH:i:s', 'Y-m-d\TH:i', 'Y-m-d H:i']; |
| | | foreach ($formats as $format) { |
| | | $date = DateTime::createFromFormat($format, $value); |
| | | if ($date) break; |
| | | } |
| | | } |
| | | |
| | | if (!$date) { |
| | | return __('Invalid datetime format', 'jvb'); |
| | | } |
| | | |
| | | $timestamp = $date->getTimestamp(); |
| | | |
| | | if (isset($config['min_datetime']) && $timestamp < strtotime($config['min_datetime'])) { |
| | | return sprintf(__('DateTime must be after %s', 'jvb'), $config['min_datetime']); |
| | | } |
| | | |
| | | if (isset($config['max_datetime']) && $timestamp > strtotime($config['max_datetime'])) { |
| | | return sprintf(__('DateTime must be before %s', 'jvb'), $config['max_datetime']); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | protected static function validateTime(string $value, array $config): bool|string |
| | | { |
| | | if (!preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $value)) { |
| | | return __('Time must be in HH:MM format', 'jvb'); |
| | | } |
| | | |
| | | if (isset($config['min_time']) && strtotime($value) < strtotime($config['min_time'])) { |
| | | return sprintf(__('Time must be after %s', 'jvb'), $config['min_time']); |
| | | } |
| | | |
| | | if (isset($config['max_time']) && strtotime($value) > strtotime($config['max_time'])) { |
| | | return sprintf(__('Time must be before %s', 'jvb'), $config['max_time']); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | protected static function validateSelect(string $value, array $config): bool|string |
| | | { |
| | | if (!isset($config['options']) || !array_key_exists($value, $config['options'])) { |
| | | return __('Invalid selection', 'jvb'); |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | protected static function validateRadio(string $value, array $config): bool|string |
| | | { |
| | | return static::validateSelect($value, $config); |
| | | } |
| | | |
| | | protected static function validateSet(array|string $value, array $config): bool|string |
| | | { |
| | | if (!is_array($value)) { |
| | | $value = explode(',', $value); |
| | | } |
| | | |
| | | if (!isset($config['options'])) { |
| | | return true; |
| | | } |
| | | |
| | | $invalid = array_diff($value, array_keys($config['options'])); |
| | | if (!empty($invalid)) { |
| | | return __('Invalid selections', 'jvb'); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | protected static function validateCheckbox(array|string $value, array $config): bool|string |
| | | { |
| | | return static::validateSet($value, $config); |
| | | } |
| | | |
| | | protected static function validateImage(int $value, array $config): bool|string |
| | | { |
| | | if (!wp_attachment_is_image($value)) { |
| | | return __('Invalid image', 'jvb'); |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | protected static function validateGallery(array|string $value, array $config): bool|string |
| | | { |
| | | $ids = is_array($value) ? $value : explode(',', $value); |
| | | |
| | | if (!empty($config['max_images']) && count($ids) > $config['max_images']) { |
| | | return sprintf(__('Maximum of %d images allowed', 'jvb'), $config['max_images']); |
| | | } |
| | | |
| | | foreach ($ids as $id) { |
| | | if (absint($id) <= 0 || !wp_attachment_is_image(absint($id))) { |
| | | return __('One or more invalid images', 'jvb'); |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | protected static function validateTaxonomy(array|string $value, array $config): bool|string |
| | | { |
| | | if (!is_array($value)) { |
| | | $value = explode(',', $value); |
| | | } |
| | | |
| | | $taxonomy = (str_starts_with($config['taxonomy'], BASE)) |
| | | ? $config['taxonomy'] |
| | | : BASE . $config['taxonomy']; |
| | | |
| | | foreach ($value as $term_id) { |
| | | if (!term_exists((int)$term_id, $taxonomy)) { |
| | | return __('Invalid term selected', 'jvb'); |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | protected static function validateUser(array|string $value, array $config): bool|string |
| | | { |
| | | if (!is_array($value)) { |
| | | $value = explode(',', $value); |
| | | } |
| | | |
| | | foreach ($value as $user_id) { |
| | | if (!get_userdata((int)$user_id)) { |
| | | return __('Invalid user selected', 'jvb'); |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | protected static function validateLocation(array $value, array $config): bool|string |
| | | { |
| | | if (!is_array($value)) { |
| | | return __('Location must be an array', 'jvb'); |
| | | } |
| | | |
| | | $required_fields = ['lat', 'lng']; |
| | | foreach ($required_fields as $field) { |
| | | if (!isset($value[$field]) || !is_numeric($value[$field])) { |
| | | return __('Invalid location coordinates', 'jvb'); |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | protected static function validateRepeater(array $value, array $config): bool|string |
| | | { |
| | | if (!is_array($value)) { |
| | | return __('Invalid repeater data', 'jvb'); |
| | | } |
| | | |
| | | if (isset($config['min_rows']) && count($value) < $config['min_rows']) { |
| | | return sprintf(__('Minimum of %d rows required', 'jvb'), $config['min_rows']); |
| | | } |
| | | |
| | | if (isset($config['max_rows']) && count($value) > $config['max_rows']) { |
| | | return sprintf(__('Maximum of %d rows allowed', 'jvb'), $config['max_rows']); |
| | | } |
| | | |
| | | foreach ($value as $row) { |
| | | foreach ($config['fields'] as $field_name => $field_config) { |
| | | if (!isset($row[$field_name])) { |
| | | continue; |
| | | } |
| | | $field_config['name'] = $field_name; |
| | | $result = static::validate($row[$field_name], $field_config); |
| | | if ($result !== true) { |
| | | return is_array($result) ? array_values($result)[0] : $result; |
| | | } |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | protected static function validateGroup(array $value, array $config): bool|string |
| | | { |
| | | if (!is_array($value)) { |
| | | return __('Invalid group data', 'jvb'); |
| | | } |
| | | |
| | | if (!empty($config['fields']) && is_array($config['fields'])) { |
| | | foreach ($config['fields'] as $field_name => $field_config) { |
| | | if (isset($value[$field_name])) { |
| | | $field_config['name'] = $field_name; |
| | | $result = static::validate($value[$field_name], $field_config); |
| | | if ($result !== true) { |
| | | return is_array($result) ? array_values($result)[0] : $result; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | protected static function validateTagList(array $value, array $config): bool|string |
| | | { |
| | | if (!is_array($value)) { |
| | | return __('Invalid data format', 'jvb'); |
| | | } |
| | | |
| | | if (isset($config['min_items']) && count($value) < $config['min_items']) { |
| | | return sprintf(__('Minimum of %d items required', 'jvb'), $config['min_items']); |
| | | } |
| | | |
| | | if (isset($config['max_items']) && count($value) > $config['max_items']) { |
| | | return sprintf(__('Maximum of %d items allowed', 'jvb'), $config['max_items']); |
| | | } |
| | | |
| | | if (!isset($config['fields']) || !is_array($config['fields'])) { |
| | | return true; |
| | | } |
| | | |
| | | foreach ($value as $row) { |
| | | if (!is_array($row)) { |
| | | continue; |
| | | } |
| | | |
| | | foreach ($config['fields'] as $field_name => $field_config) { |
| | | if (!isset($row[$field_name])) { |
| | | continue; |
| | | } |
| | | |
| | | $field_config['name'] = $field_name; |
| | | $result = static::validate($row[$field_name], $field_config); |
| | | if ($result !== true) { |
| | | return is_array($result) ? array_values($result)[0] : $result; |
| | | } |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | protected static function validateTrueFalse(mixed $value, array $config): bool |
| | | { |
| | | return true; // Boolean values are always valid after sanitization |
| | | } |
| | | } |
| | |
| | | require(JVB_DIR . '/inc/meta/Field.php'); //Single field with value, dirty state |
| | | require(JVB_DIR . '/inc/meta/Storage.php'); //Persistence layer |
| | | require(JVB_DIR . '/inc/meta/MetaTypeManager.php'); //Keep as is |
| | | require(JVB_DIR . '/inc/meta/MetaValidator.php'); //Keep as is |
| | | require(JVB_DIR . '/inc/meta/Validator.php'); //Keep as is |
| | | |
| | | |
| | | require(JVB_DIR . '/inc/meta/MetaRenderer.php'); //decouple from manager |
| | | require(JVB_DIR . '/inc/meta/MetaForm.php'); //decouple from manager |
| | | require(JVB_DIR . '/inc/meta/Render.php'); |
| | | require(JVB_DIR . '/inc/meta/Form.php'); |
| | | require(JVB_DIR . '/inc/meta/Registry.php'); |
| | | require(JVB_DIR . '/inc/meta/Sanitizer.php'); |
| | | |
| | | //OLD SYSTEM |
| | | require(JVB_DIR . '/inc/meta/MetaManager.php'); |
| | | require(JVB_DIR . '/inc/meta/MetaRegistry.php'); |
| | | require(JVB_DIR . '/inc/meta/MetaSanitizer.php'); |
| | | // require(JVB_DIR . '/inc/meta/MetaManager.php'); |
| | |
| | | error_log('JVB: Starting table creation process'); |
| | | error_log('JVB: Memory usage at start: ' . memory_get_usage(true) / 1024 / 1024 . ' MB'); |
| | | |
| | | $tables = $calendar = $integrations = $karma = $stats = $invitable = $verifyEntry = $approval = $trackChanges = []; |
| | | $tables = $calendar = $integrations = $karma = $stats = $verifyEntry = $approval = $trackChanges = []; |
| | | $invitable = [ |
| | | 'roles' => [], |
| | | 'terms' => [] |
| | | ]; |
| | | |
| | | |
| | | // Basic tables (these worked fine) |
| | | try { |
| | |
| | | // $tables = array_merge($tables, $this->umamiTracking()); |
| | | // } |
| | | } |
| | | if (array_key_exists('can_invite', $this->JVB_MEMBERSHIP) && is_array($this->JVB_MEMBERSHIP['can_invite'])) { |
| | | foreach ($this->JVB_MEMBERSHIP['can_invite'] as $role => $canInvite) { |
| | | $invitable[$role]['can_invite'] = $canInvite; |
| | | } |
| | | if (array_key_exists('can_invite', $this->JVB_MEMBERSHIP) && |
| | | is_array($this->JVB_MEMBERSHIP['can_invite'])) { |
| | | $invitable['roles'] = $this->JVB_MEMBERSHIP['can_invite']; |
| | | } |
| | | |
| | | // if (jvbCheck('social', $this->JVB_SITE) || jvbCheck('gmb', $this->JVB_SITE) || jvbCheck('square', $this->JVB_SITE) || jvbCheck('helcim', $this->JVB_SITE)) { |
| | |
| | | $trackChanges[$type] = $config; |
| | | } |
| | | if (array_key_exists('invitable', $config) && $config['invitable']) { |
| | | foreach ($config['for_content'] as $content) { |
| | | $invitable[$content]['to_terms'][] = $type; |
| | | } |
| | | $invitable['terms'][] = $type; |
| | | } |
| | | if (array_key_exists('verify_entry', $config) && $config['verify_entry']) { |
| | | $verifyEntry[$type] = $config; |
| | |
| | | |
| | | // RE-ENABLE other table types |
| | | try { |
| | | if (!empty($invitable)) { |
| | | error_log('JVB: Creating invitation tables...'); |
| | | if (!empty($invitable['roles']) || !empty($invitable['terms'])) { |
| | | error_log('JVB: Creating invitation table...'); |
| | | $invitationTables = $this->invitationTables($invitable); |
| | | error_log('JVB: Invitation tables created: ' . count($invitationTables)); |
| | | error_log('JVB: Invitation table created: ' . count($invitationTables)); |
| | | $tables = array_merge($tables, $invitationTables); |
| | | error_log('JVB: Memory after invitations: ' . memory_get_usage(true) / 1024 / 1024 . ' MB'); |
| | | } |
| | | } catch (Exception $e) { |
| | | error_log("JVB: Error creating invitation tables: " . $e->getMessage()); |
| | | error_log("JVB: Error creating invitation table: " . $e->getMessage()); |
| | | } |
| | | |
| | | // Store config for later use |
| | | update_option(BASE.'invitation_config', $invitable); |
| | | |
| | | try { |
| | | if (!empty($approval)) { |
| | | error_log('JVB: Creating approval tables...'); |
| | |
| | | ]; |
| | | } |
| | | |
| | | protected function invitationTables($types) |
| | | { |
| | | |
| | | $tables = []; |
| | | foreach ($types as $role => $config) { |
| | | $definitions = "( |
| | | `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, |
| | | `name` varchar(255) NOT NULL, |
| | | `email` varchar(255) NOT NULL, |
| | | `invitation_token` varchar(64) NOT NULL, |
| | | `status` enum('pending', 'accepted', 'rejected', 'expired','revoked') DEFAULT 'pending', |
| | | `inviters` JSON NOT NULL,"; |
| | | foreach($config['to_terms']??[] as $term) { |
| | | $definitions .= "`to_{$term}` {$this->termIDType} DEFAULT NULL,"; |
| | | } |
| | | $definitions .= "`new_user_id` bigint(20) NOT NULL, |
| | | `expires_at` datetime NOT NULL, |
| | | `accepted_at` datetime DEFAULT NULL, |
| | | `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| | | `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
| | | PRIMARY KEY (`id`), |
| | | UNIQUE KEY `unique_email` (`email`), |
| | | KEY `token_lookup` (`invitation_token`), |
| | | KEY `status_expiry` (`status`, `expires_at`), |
| | | KEY `name_status` (`name`, `status`) |
| | | )"; |
| | | foreach($config['to_terms']??[] as $term) { |
| | | $definitions .= "CONSTRAINT `{$this->base}_{$term}_link` FOREIGN KEY (`to_{$term}`) |
| | | REFERENCES `{$this->wpdb->terms}` (`term_id`) ON DELETE CASCADE"; |
| | | } |
| | | |
| | | $tables['invitations_'.$role] = $definitions; |
| | | protected function invitationTables(array $config): array |
| | | { |
| | | if (empty($config['roles']) && empty($config['terms'])) { |
| | | return []; |
| | | } |
| | | |
| | | return $tables; |
| | | } |
| | | $definitions = "( |
| | | `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, |
| | | `name` varchar(255) NOT NULL, |
| | | `email` varchar(255) NOT NULL, |
| | | `invitation_token` varchar(255) NOT NULL, |
| | | `invited_role` varchar(50) NOT NULL COMMENT 'Role being invited to', |
| | | `status` enum('pending','accepted','rejected','expired','revoked') DEFAULT 'pending', |
| | | `inviters` JSON NOT NULL COMMENT 'Array of {user_id, invited_at}', |
| | | `new_user_id` {$this->userIDType} DEFAULT NULL, |
| | | `expires_at` datetime NOT NULL, |
| | | `accepted_at` datetime DEFAULT NULL, |
| | | `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| | | `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
| | | "; |
| | | |
| | | // Add term columns for all invitable taxonomies |
| | | foreach ($config['terms'] ?? [] as $taxonomy) { |
| | | $definitions .= "`to_{$taxonomy}` {$this->termIDType} DEFAULT NULL,"; |
| | | } |
| | | |
| | | $definitions .= "PRIMARY KEY (`id`), |
| | | UNIQUE KEY `unique_email_role` (`email`, `invited_role`), |
| | | KEY `token_lookup` (`invitation_token`), |
| | | KEY `status_expiry` (`status`, `expires_at`), |
| | | KEY `role_status` (`invited_role`, `status`), |
| | | KEY `email_status` (`email`, `status`), |
| | | "; |
| | | |
| | | // Add foreign key constraints for terms |
| | | $constraints = []; |
| | | foreach ($config['terms'] ?? [] as $taxonomy) { |
| | | $constraints[] = "CONSTRAINT `{$this->base}invitations_{$taxonomy}_fk` |
| | | FOREIGN KEY (`to_{$taxonomy}`) |
| | | REFERENCES `{$this->wpdb->terms}` (`term_id`) |
| | | ON DELETE SET NULL"; |
| | | } |
| | | |
| | | // Add user foreign key |
| | | $constraints[] = "CONSTRAINT `{$this->base}invitations_user_fk` |
| | | FOREIGN KEY (`new_user_id`) |
| | | REFERENCES `{$this->userTable}` (`ID`) |
| | | ON DELETE SET NULL"; |
| | | |
| | | $definitions .= implode(',', $constraints); |
| | | $definitions .= ")"; |
| | | |
| | | return ['invitations' => $definitions]; |
| | | } |
| | | |
| | | protected function trackChangesTables($types) |
| | | { |
| | |
| | | } |
| | | |
| | | use JVBase\managers\RoleManager; |
| | | use JVBase\meta\MetaRegistry; |
| | | use JVBase\meta\Registry; |
| | | use JVBase\rest\RegisterRoutes; |
| | | |
| | | class ContentRegistry |
| | |
| | | return; |
| | | } |
| | | |
| | | $meta_registry = new MetaRegistry($fields, $type, $object_type); |
| | | $meta_registry = new Registry($fields, $type, $object_type); |
| | | $meta_registry->registerMetaFields(); |
| | | } |
| | | |
| | |
| | | new OptionsRegistry($fields); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Register REST routes |
| | | */ |
| | | public function registerRestRoutes(): void |
| | | { |
| | | // Register routes for post types |
| | | foreach (JVB_CONTENT as $slug => $config) { |
| | | $this->registerRoute($slug, $config); |
| | | } |
| | | |
| | | // Register routes for content taxonomies |
| | | foreach (JVB_TAXONOMY as $slug => $config) { |
| | | if (jvbCheck('is_content', $config)) { |
| | | $this->registerRoute($slug, $config, 'content_tax'); |
| | | } |
| | | } |
| | | |
| | | // Register routes for options |
| | | if (!empty(JVB_OPTIONS)) { |
| | | $this->registerRoute('options', JVB_OPTIONS['fields'], 'options'); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Register a single route |
| | | */ |
| | | protected function registerRoute(string $slug, array $config, string $type = ''): void |
| | | { |
| | | JVB()->addRoute($slug, new RegisterRoutes($slug, $config)); |
| | | } |
| | | } |
| | | |
| | | new ContentRegistry(); |
| | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | use JVBase\meta\MetaManager; |
| | | |
| | | use JVBase\meta\Form; |
| | | use JVBase\meta\Meta; |
| | | |
| | | class OptionsRegistry |
| | | { |
| | |
| | | */ |
| | | public function registerOptions(): void |
| | | { |
| | | $meta = new MetaManager(null, 'options'); |
| | | $meta = Meta::forOptions('options'); |
| | | foreach ($this->fields as $field_name => $field) { |
| | | if (in_array($field_name, ['common', 'fields'])) { |
| | | continue; |
| | |
| | | |
| | | // Add default value if not exists |
| | | if (get_option($option_name) === false) { |
| | | add_option($option_name, $field['default'] ?? $meta->getDefaultValue($field['type'])); |
| | | add_option($option_name, $field['default'] ??''); |
| | | } |
| | | |
| | | // |
| | |
| | | $name = $args['name']; |
| | | $value = get_option(BASE . $name); |
| | | |
| | | // Use MetaForm to render if available |
| | | if (class_exists('\JVBase\meta\MetaForm')) { |
| | | $form = new \JVBase\meta\MetaForm(); |
| | | echo $form->renderField($name, $field, $value); |
| | | } |
| | | echo Form::render($name, $value, $field); |
| | | } |
| | | |
| | | private function getFieldType(string $type): string |
| | |
| | | default => 'string' |
| | | }; |
| | | } |
| | | |
| | | private function getSanitizeCallback(array $field): callable |
| | | { |
| | | return function($value) use ($field) { |
| | | $manager = new \JVBase\meta\MetaManager(); |
| | | return $manager->sanitizeField($value, $field); |
| | | }; |
| | | } |
| | | } |
| | |
| | | use JVBase\managers\CRUD; |
| | | use JVBase\utility\Features; |
| | | use WP_Post; |
| | | use JVBase\meta\MetaRegistry; |
| | | use JVBase\meta\Registry; |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | |
| | | register_post_type($this->post_type, $args); |
| | | |
| | | if (!empty($this->fields)) { |
| | | $meta_registry = new MetaRegistry($this->fields, $this->slug, 'post'); |
| | | $meta_registry = new Registry($this->fields, $this->slug, 'post'); |
| | | $meta_registry->registerMetaFields(); |
| | | } |
| | | } |
| | |
| | | <?php |
| | | namespace JVBase\registry; |
| | | |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\MetaRegistry; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\meta\Registry; |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | |
| | | |
| | | $this->maybeAddRewriteRule($args['rewrite']); |
| | | if (!empty($this->fields)) { |
| | | $meta_registry = new MetaRegistry($this->fields, $this->slug, 'term'); |
| | | $meta_registry = new Registry($this->fields, $this->slug, 'term'); |
| | | $meta_registry->registerMetaFields(); |
| | | } |
| | | } |
| | |
| | | ]; |
| | | |
| | | // Get meta manager for this term |
| | | $meta = new MetaManager($term_id, 'term'); |
| | | $meta = Meta::forTerm($term_id); |
| | | $values = $meta->getAll(array_keys($custom_fields)); |
| | | |
| | | // Process each custom field |
| | |
| | | <?php |
| | | namespace JVBase\registry; |
| | | use JVBase\meta\MetaRegistry; |
| | | use JVBase\meta\Registry; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | |
| | | public function registerFields():void |
| | | { |
| | | if (!empty($this->fields)) { |
| | | $meta_registry = new MetaRegistry($this->fields, $this->slug, 'term'); |
| | | $meta_registry = new Registry($this->fields, $this->slug, 'term'); |
| | | $meta_registry->registerMetaFields(); |
| | | } |
| | | } |
| | |
| | | /** |
| | | * Verify action-specific nonce (e.g., 'dash-{user_id}') |
| | | */ |
| | | public static function verifyActionNonce(WP_REST_Request $request, string $actionPrefix, string $header = 'X-Action-Nonce'): bool|WP_Error |
| | | public static function verifyActionNonce(WP_REST_Request $request, string $actionPrefix, string $header = 'action_nonce'): bool|WP_Error |
| | | { |
| | | $userId = $request->get_param('user') ?: get_current_user_id(); |
| | | $action = $actionPrefix . $userId; |
| | |
| | | } |
| | | |
| | | /** |
| | | * Combined permission check: user match + rate limit |
| | | */ |
| | | public static function userMatchWithRateLimit(WP_REST_Request $request): bool|WP_Error |
| | | { |
| | | static $rateLimiter = null; |
| | | |
| | | if ($rateLimiter === null) { |
| | | $rateLimiter = new RateLimiter(); |
| | | } |
| | | |
| | | // Check rate limit first |
| | | if (!$rateLimiter->checkLimit($request)) { |
| | | return new WP_Error( |
| | | 'rate_limit', |
| | | 'Too many requests. Please wait before trying again.', |
| | | ['status' => 429] |
| | | ); |
| | | } |
| | | |
| | | return self::userMatch($request); |
| | | } |
| | | |
| | | /** |
| | | * Create a custom permission callback combining multiple checks |
| | | * |
| | | * Usage: |
| | |
| | | $check === 'admin' => self::isAdmin($request), |
| | | $check === 'verified' => self::isVerified($request), |
| | | $check === 'user' => self::userMatch($request), |
| | | $check === 'nonce' => self::verifyNonce($request), |
| | | is_array($check) && isset($check['role']) => self::hasRole($request, $check['role']), |
| | | is_array($check) && isset($check['roles']) => self::hasAnyRole($request, $check['roles']), |
| | | is_array($check) && isset($check['capability']) => self::hasCapability($request, $check['capability']), |
| | | is_array($check) && isset($check['actionNonce']) => self::verifyActionNonce($request, $check['actionNonce']), |
| | | is_callable($check) => $check($request), |
| | | default => true, |
| | | }; |
| | |
| | | $check === 'admin' => self::isAdmin($request), |
| | | $check === 'verified' => self::isVerified($request), |
| | | $check === 'user' => self::userMatch($request), |
| | | $check === 'nonce' => self::verifyNonce($request), |
| | | is_array($check) && isset($check['role']) => self::hasRole($request, $check['role']), |
| | | is_array($check) && isset($check['roles']) => self::hasAnyRole($request, $check['roles']), |
| | | is_array($check) && isset($check['capability']) => self::hasCapability($request, $check['capability']), |
| | | is_array($check) && isset($check['actionNonce']) => self::verifyActionNonce($request, $check['actionNonce']), |
| | | is_callable($check) => $check($request), |
| | | default => false, |
| | | }; |
| | | |
| | | // If it's a successful check (true), pass |
| | | if ($result === true) { |
| | | return true; |
| | | } |
| | | |
| | | // Track last error for reporting |
| | | if (is_wp_error($result)) { |
| | | $lastError = $result; |
| | | } |
| | |
| | | exit; // Exit if accessed directly |
| | | } |
| | | /** |
| | | * @deprecated Using RateLimits.php |
| | | * Handles rate limiting for REST requests |
| | | */ |
| | | class RateLimiter |
| | |
| | | <?php |
| | | namespace JVBase\rest; |
| | | |
| | | use WP_REST_Request; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Handles rate limiting for REST requests |
| | | */ |
| | | class RateLimits |
| | | { |
| | | protected string $cacheGroup = 'jvb_rate_limits'; |
| | | |
| | | protected array $defaults = [ |
| | | 'GET' => ['count' => 1000, 'window' => 3600], |
| | | 'POST' => ['count' => 100, 'window' => 3600], |
| | | 'PUT' => ['count' => 100, 'window' => 3600], |
| | | 'PATCH' => ['count' => 100, 'window' => 3600], |
| | | 'DELETE' => ['count' => 50, 'window' => 3600], |
| | | ]; |
| | | |
| | | /** |
| | | * Check if request is within rate limits |
| | | * |
| | | * @param WP_REST_Request $request |
| | | * @param int|null $limit Optional custom limit (overrides defaults) |
| | | * @param int|null $window Optional custom window in seconds (overrides defaults) |
| | | * @return bool True if within limits, false if exceeded |
| | | */ |
| | | public function checkLimit(WP_REST_Request $request, ?int $limit = null, ?int $window = null): bool |
| | | { |
| | | $method = $request->get_method(); |
| | | $default = $this->defaults[$method] ?? $this->defaults['GET']; |
| | | |
| | | $limit = $limit ?? $default['count']; |
| | | $window = $window ?? $default['window']; |
| | | |
| | | $key = $this->getCacheKey($request, $window); |
| | | $current = (int) wp_cache_get($key, $this->cacheGroup); |
| | | |
| | | if ($current >= $limit) { |
| | | return false; |
| | | } |
| | | |
| | | // Increment or initialize |
| | | if ($current === 0) { |
| | | wp_cache_set($key, 1, $this->cacheGroup, $window); |
| | | } else { |
| | | wp_cache_incr($key, 1, $this->cacheGroup); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Get remaining requests for current window |
| | | */ |
| | | public function getRemaining(WP_REST_Request $request, ?int $limit = null, ?int $window = null): int |
| | | { |
| | | $method = $request->get_method(); |
| | | $default = $this->defaults[$method] ?? $this->defaults['GET']; |
| | | |
| | | $limit = $limit ?? $default['count']; |
| | | $window = $window ?? $default['window']; |
| | | |
| | | $key = $this->getCacheKey($request, $window); |
| | | $current = (int) wp_cache_get($key, $this->cacheGroup); |
| | | |
| | | return max(0, $limit - $current); |
| | | } |
| | | |
| | | /** |
| | | * Reset rate limit for a request pattern |
| | | */ |
| | | public function reset(WP_REST_Request $request, ?int $window = null): void |
| | | { |
| | | $method = $request->get_method(); |
| | | $default = $this->defaults[$method] ?? $this->defaults['GET']; |
| | | $window = $window ?? $default['window']; |
| | | |
| | | $key = $this->getCacheKey($request, $window); |
| | | wp_cache_delete($key, $this->cacheGroup); |
| | | } |
| | | |
| | | protected function getCacheKey(WP_REST_Request $request, int $window): string |
| | | { |
| | | $ip = $request->get_header('X-Forwarded-For') ?: ($_SERVER['REMOTE_ADDR'] ?? 'unknown'); |
| | | $userId = get_current_user_id(); |
| | | $method = $request->get_method(); |
| | | $route = $request->get_route(); |
| | | |
| | | // Include window in key so different windows don't collide |
| | | return "rate:{$ip}:{$userId}:{$method}:{$route}:{$window}"; |
| | | } |
| | | } |
| | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\meta\Meta; |
| | | use WP_Error; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | |
| | | /** |
| | | * @deprecated |
| | | */ |
| | | class RegisterRoutes extends RestRouteManager { |
| | | |
| | | protected array $config; |
| | |
| | | 'error' => 'User cannot change options' |
| | | ]; |
| | | } |
| | | $meta = new MetaManager(null, $this->route); |
| | | $meta = Meta::forOptions($this->route); |
| | | } else { |
| | | $termID = (int) $data['term_id']; |
| | | if (!user_can($userID, 'manage_'.$this->route.'_'.$termID)) { |
| | |
| | | 'error' => 'User cannot manage this '.$this->route |
| | | ]; |
| | | } |
| | | $meta = new MetaManager($termID, 'term'); |
| | | $meta = Meta::forTerm($termID); |
| | | } |
| | | |
| | | $results = []; |
| | |
| | | |
| | | foreach ($allowed as $name => $value) { |
| | | if (empty($value)) { |
| | | $results[] = $meta->deleteValue($name); |
| | | $results[] = $meta->delete($name); |
| | | } else { |
| | | $results[] = $meta->updateValue($name, $value); |
| | | $results[] = $meta->set($name, $value); |
| | | } |
| | | } |
| | | //Allow plugins & themes to process extra data here |
| | |
| | | return; |
| | | } |
| | | |
| | | $termMeta = new MetaManager($termID, 'term'); |
| | | $managers = explode(',', $termMeta->getValue('managers')); |
| | | $owner = explode(',', $termMeta->getValue('owner')); |
| | | $termMeta = Meta::forTerm($termID); |
| | | $managers = explode(',', $termMeta->get('managers')); |
| | | $owner = explode(',', $termMeta->get('owner')); |
| | | |
| | | $owners = array_unique(array_merge($managers, $owner)); |
| | | |
| | |
| | | return self::success($data); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Alias for backward compatibility |
| | | */ |
| | | class ResponseBuilder extends Response {} |
| | |
| | | <?php |
| | | namespace JVBase\rest; |
| | | |
| | | use JVBase\managers\Cache; |
| | | use JVBase\utility\Features; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | use Exception; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Base REST Route Manager |
| | | * |
| | | * Provides shared utilities for route handlers. Route registration |
| | | * should use the Route builder class. |
| | | * |
| | | * Responsibilities: |
| | | * - Cache management (headers, invalidation) |
| | | * - Query building helpers |
| | | * - Validation utilities |
| | | * - Audit logging |
| | | */ |
| | | abstract class Rest |
| | | { |
| | | protected string $namespace = 'jvb/v1'; |
| | | protected ?Cache $cache = null; |
| | | protected string $cacheName = ''; |
| | | protected int $cacheTtl = 3600; |
| | | protected static ?string $action; |
| | | |
| | | public function __construct() |
| | | { |
| | | if ($this->cacheName !== '') { |
| | | $this->cache = Cache::for($this->cacheName, $this->cacheTtl); |
| | | } |
| | | |
| | | add_action('rest_api_init', [$this, 'registerRoutes']); |
| | | } |
| | | |
| | | /** |
| | | * Register routes - implement using Route builder |
| | | */ |
| | | abstract public function registerRoutes(): void; |
| | | |
| | | // ========================================================================= |
| | | // RESPONSE HELPERS |
| | | // ========================================================================= |
| | | |
| | | protected function success(array $data = [], int $status = 200): WP_REST_Response |
| | | { |
| | | return Response::success($data, $status); |
| | | } |
| | | |
| | | protected function error(string $message, string $code = 'error', int $status = 400, ?string $field = null): WP_REST_Response |
| | | { |
| | | return Response::error($message, $code, $status, $field); |
| | | } |
| | | |
| | | protected function validationError(array $errors): WP_REST_Response |
| | | { |
| | | return Response::validationError($errors); |
| | | } |
| | | |
| | | protected function notFound(string $message = 'Not found'): WP_REST_Response |
| | | { |
| | | return Response::notFound($message); |
| | | } |
| | | |
| | | protected function forbidden(string $message = 'Forbidden'): WP_REST_Response |
| | | { |
| | | return Response::forbidden($message); |
| | | } |
| | | |
| | | protected function unauthorized(string $message = 'Unauthorized'): WP_REST_Response |
| | | { |
| | | return Response::unauthorized($message); |
| | | } |
| | | |
| | | protected function queued(string $operationId, string $message = 'Queued for processing'): WP_REST_Response |
| | | { |
| | | return Response::queued($operationId, $message); |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // CACHE MANAGEMENT |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Check request headers for conditional caching (ETag, If-Modified-Since) |
| | | */ |
| | | protected function checkHeaders(WP_REST_Request $request, string $cacheKey): ?WP_REST_Response |
| | | { |
| | | if (!$this->cache) { |
| | | return null; |
| | | } |
| | | |
| | | $cached = $this->cache->get($cacheKey); |
| | | if (!$cached) { |
| | | return null; |
| | | } |
| | | |
| | | $etag = $request->get_header('If-None-Match'); |
| | | $cachedEtag = $cached['etag'] ?? null; |
| | | |
| | | if ($etag && $cachedEtag && $etag === $cachedEtag) { |
| | | return new WP_REST_Response(null, 304); |
| | | } |
| | | |
| | | $ifModifiedSince = $request->get_header('If-Modified-Since'); |
| | | $lastModified = $cached['last_modified'] ?? null; |
| | | |
| | | if ($ifModifiedSince && $lastModified) { |
| | | if (strtotime($ifModifiedSince) >= strtotime($lastModified)) { |
| | | return new WP_REST_Response(null, 304); |
| | | } |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Add cache headers to response |
| | | */ |
| | | protected function addCacheHeaders(WP_REST_Response $response, int $maxAge = 300): WP_REST_Response |
| | | { |
| | | $response->header('Cache-Control', "private, max-age={$maxAge}"); |
| | | $response->header('Vary', 'Cookie'); |
| | | $response->header('ETag', '"' . md5(serialize($response->get_data())) . '"'); |
| | | $response->header('Last-Modified', gmdate('D, d M Y H:i:s') . ' GMT'); |
| | | |
| | | return $response; |
| | | } |
| | | |
| | | /** |
| | | * Store response in cache with metadata |
| | | */ |
| | | protected function cacheResponse(string $key, array $data): void |
| | | { |
| | | if (!$this->cache) { |
| | | return; |
| | | } |
| | | |
| | | $this->cache->set($key, [ |
| | | 'data' => $data, |
| | | 'etag' => '"' . md5(serialize($data)) . '"', |
| | | 'last_modified' => gmdate('D, d M Y H:i:s') . ' GMT', |
| | | ]); |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // TIMESTAMP FORMATTING |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Convert MySQL datetime to ISO 8601 timestamp |
| | | */ |
| | | protected function formatTimestamp(?string $mysqlDatetime): ?string |
| | | { |
| | | return Response::formatTimestamp($mysqlDatetime); |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // QUERY BUILDING |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Apply taxonomy filters to WP_Query args |
| | | */ |
| | | protected function applyTaxonomyFilters(array $args, array $data):array |
| | | { |
| | | // Handle JSON-encoded taxonomy data |
| | | if (array_key_exists('taxonomy', $data) && is_string($data['taxonomy'])) { |
| | | $data['taxonomy'] = json_decode($data['taxonomy'], true); |
| | | } |
| | | |
| | | $taxonomies = $data['taxonomies'] ?? $data['taxonomy'] ?? []; |
| | | $taxQuery = []; |
| | | |
| | | foreach($taxonomies as $taxonomy => $terms) { |
| | | // Better validation: check if taxonomy actually exists |
| | | if (!taxonomy_exists(jvbCheckBase($taxonomy))) { |
| | | continue; |
| | | } |
| | | |
| | | $taxQuery[] = [ |
| | | 'taxonomy' => jvbCheckBase($taxonomy), |
| | | 'field' => 'term_id', |
| | | 'terms' => array_map( |
| | | 'absint', |
| | | is_array($terms) ? $terms : explode(',', $terms) |
| | | ), |
| | | 'operator' => 'IN' |
| | | ]; |
| | | } |
| | | |
| | | if (!empty($taxQuery)) { |
| | | // Match 'all' = AND, anything else = OR |
| | | $relation = ($data['match'] ?? 'all') === 'all' ? 'AND' : 'OR'; |
| | | |
| | | $args['tax_query'] = array_merge([ |
| | | 'relation' => $relation, |
| | | ], $taxQuery); |
| | | } |
| | | |
| | | // Keep existing author filtering logic |
| | | $authorQuery = []; |
| | | foreach (jvbAuthorUsers() as $type) { |
| | | if (array_key_exists($type, $data)) { |
| | | $artist_ids = array_map( |
| | | 'absint', |
| | | is_array($data[$type]) ? |
| | | $data[$type] : |
| | | explode(',', $data[$type]) |
| | | ); |
| | | $authorQuery = array_merge($authorQuery, $artist_ids); |
| | | } |
| | | } |
| | | if (!empty($authorQuery)) { |
| | | $args['author__in'] = array_unique($authorQuery); |
| | | } |
| | | |
| | | return $args; |
| | | } |
| | | |
| | | /** |
| | | * Apply order/sort filters to WP_Query args |
| | | */ |
| | | protected function applyOrderFilters(array $args, array $data):array |
| | | { |
| | | // Check for custom order first |
| | | $customArgs = $this->applyCustomOrder($args, $data); |
| | | if ($customArgs !== null) { |
| | | $order = (array_key_exists('order', $data)) ? strtoupper($data['order']) : 'DESC'; |
| | | $customArgs['order'] = (in_array($order, ['ASC', 'DESC'])) ? $order : 'DESC'; |
| | | return $customArgs; |
| | | } |
| | | |
| | | //Handle random |
| | | if (array_key_exists('orderby', $data) && $data['orderby'] === 'random') { |
| | | $current_seed = jvbGetRandomSeed(); |
| | | $args['orderby'] = 'RAND(' . $current_seed . ')'; |
| | | unset($args['order']); |
| | | return $args; |
| | | } |
| | | |
| | | if (in_array($data['orderby'], ['date', 'modified', 'title', 'alphabetical'])) { |
| | | if ($data['orderby'] === 'date' && $this->isTimeline($args, $data)) { |
| | | $args['meta_key'] = BASE . 'latest_date'; |
| | | $args['orderby'] = 'meta_value_num'; |
| | | } else { |
| | | $args['orderby'] = ($data['orderby'] === 'alphabetical') ? 'title' : $data['orderby']; |
| | | } |
| | | |
| | | } else { |
| | | switch ($data['orderby']) { |
| | | case 'popularity': |
| | | $args['meta_key'] = BASE.'upvotes'; |
| | | $args['orderby'] = 'meta_value_num'; |
| | | break; |
| | | case 'karma': |
| | | $args['meta_key'] = BASE.'karma'; |
| | | $args['orderby'] = 'meta_value_num'; |
| | | break; |
| | | case 'unpopularity': |
| | | $args['meta_key'] = BASE.'downvotes'; |
| | | $args['orderby'] = 'meta_value_num'; |
| | | break; |
| | | case 'favourites': |
| | | $args['meta_key'] = BASE.'total_favourites'; |
| | | $args['orderby'] = 'meta_value_num'; |
| | | break; |
| | | case 'date': |
| | | default: |
| | | $args['orderby'] = 'date'; |
| | | break; |
| | | } |
| | | } |
| | | $order = (array_key_exists('order', $data)) ? strtoupper($data['order']) : 'DESC'; |
| | | $args['order'] = (in_array($order, ['ASC', 'DESC'])) ? $order : 'DESC'; |
| | | |
| | | return $args; |
| | | } |
| | | |
| | | /** |
| | | * Apply custom order if defined in content/taxonomy/user config |
| | | * |
| | | * @param array $args WP_Query args |
| | | * @param array $data Request data |
| | | * @return array|null Modified args if custom order found, null otherwise |
| | | */ |
| | | protected function applyCustomOrder(array $args, array $data): ?array |
| | | { |
| | | $orderby = $data['orderby'] ?? ''; |
| | | |
| | | // Skip if no orderby or it's a standard order |
| | | if (empty($orderby) || in_array($orderby, ['date', 'modified', 'title', 'alphabetical', 'random', 'popularity', 'karma', 'unpopularity', 'favourites'])) { |
| | | return null; |
| | | } |
| | | |
| | | // Determine content type |
| | | $post_type = is_array($args['post_type']) ? $args['post_type'][0] : $args['post_type']; |
| | | $content = jvbNoBase($post_type); |
| | | |
| | | // Get config for this content type |
| | | $config = Features::getConfig($content); |
| | | if (!$config) { |
| | | return null; |
| | | } |
| | | |
| | | // Check if this orderby is a custom order |
| | | $customOrders = $config['custom_order'] ?? []; |
| | | if (empty($customOrders) || !isset($customOrders[$orderby])) { |
| | | return null; |
| | | } |
| | | |
| | | // Get field definition |
| | | $fields = $config['fields'] ?? []; |
| | | if (!isset($fields[$orderby])) { |
| | | return null; |
| | | } |
| | | |
| | | $field = $fields[$orderby]; |
| | | |
| | | // Set meta_key |
| | | $args['meta_key'] = BASE . $orderby; |
| | | |
| | | // Determine orderby and meta_type based on field type |
| | | $fieldType = $field['type'] ?? 'text'; |
| | | $subtype = $field['subtype'] ?? ''; |
| | | |
| | | switch ($fieldType) { |
| | | case 'number': |
| | | $args['orderby'] = 'meta_value_num'; |
| | | break; |
| | | |
| | | case 'text': |
| | | $args['orderby'] = ($subtype === 'number') ? 'meta_value_num' : 'meta_value'; |
| | | break; |
| | | |
| | | case 'date': |
| | | $args['orderby'] = 'meta_value'; |
| | | $args['meta_type'] = 'DATE'; |
| | | break; |
| | | |
| | | case 'datetime': |
| | | $args['orderby'] = 'meta_value'; |
| | | $args['meta_type'] = 'DATETIME'; |
| | | break; |
| | | |
| | | case 'true_false': |
| | | case 'checkbox': |
| | | $args['orderby'] = 'meta_value'; |
| | | $args['meta_type'] = 'BINARY'; |
| | | break; |
| | | |
| | | default: |
| | | $args['orderby'] = 'meta_value'; |
| | | } |
| | | |
| | | return $args; |
| | | } |
| | | |
| | | protected function applyDateFilters(array $args, array $data):array |
| | | { |
| | | if (!array_key_exists('date-filter', $data) && !array_key_exists('dateFrom', $data)) { |
| | | return $args; |
| | | } |
| | | if (array_key_exists('dateFrom', $data)) { |
| | | $dateFrom = strtotime(sanitize_text_field($data['dateFrom'])); |
| | | $dateTo = strtotime(sanitize_text_field($data['dateTo'])); |
| | | if ($dateFrom && $dateTo) { |
| | | $args['date_query'] = [ |
| | | [ |
| | | 'after' => date('c', $dateFrom), |
| | | 'before' => date('c', $dateTo), |
| | | 'inclusive' => true, |
| | | ] |
| | | ]; |
| | | } |
| | | } else { |
| | | switch ($data['date-filter']) { |
| | | case 'today': |
| | | $args['date_query'] = [['after' => '1 day ago']]; |
| | | break; |
| | | case 'week': |
| | | $args['date_query'] = [['after' => '1 week ago']]; |
| | | break; |
| | | case 'month': |
| | | $args['date_query'] = [['after' => '1 month ago']]; |
| | | break; |
| | | case 'year': |
| | | $args['date_query'] = [['after' => '1 year ago']]; |
| | | break; |
| | | } |
| | | } |
| | | return $args; |
| | | } |
| | | |
| | | protected function applyCalendarFilters(array $args, array $data):array |
| | | { |
| | | $meta_query = []; |
| | | $today = date('Y-m-d'); |
| | | if (in_array('future', $args['post_status'])) { |
| | | $meta_query[] = [ |
| | | 'key' => 'jvb_start_date', |
| | | 'value' => $today, |
| | | 'compare' => '>=', |
| | | 'type' => 'DATE' |
| | | ]; |
| | | } |
| | | if (in_array('past', $args['post_status'])) { |
| | | $meta_query[] = [ |
| | | 'key' => 'jvb_end_date', |
| | | 'value' => $today, |
| | | 'compare' => '<', |
| | | 'type' => 'DATE' |
| | | ]; |
| | | } |
| | | if (in_array('recurring', $args['post_status'])) { |
| | | $meta_query[] = [ |
| | | 'key' => 'jvb_is_recurring', |
| | | 'value' => true, |
| | | 'compare' => '=' |
| | | ]; |
| | | } |
| | | if (!empty($meta_query)) { |
| | | $args['meta_query'] = (array_key_exists('meta_query', $args)) ? array_merge($args['meta_query'], $meta_query) : $meta_query; |
| | | } |
| | | return $args; |
| | | |
| | | } |
| | | |
| | | /** |
| | | * Apply pagination to WP_Query args |
| | | */ |
| | | protected function applyPagination(array $args, array $data): array |
| | | { |
| | | $args['posts_per_page'] = min(absint($data['per_page'] ?? 20), 100); |
| | | $args['paged'] = max(absint($data['page'] ?? 1), 1); |
| | | return $args; |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // VALIDATION |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Check if user ID matches current logged-in user |
| | | */ |
| | | protected function userCheck(int $userId): bool |
| | | { |
| | | return $userId === get_current_user_id(); |
| | | } |
| | | |
| | | /** |
| | | * Check if content type exists |
| | | */ |
| | | protected function checkContent(string $content, bool $returnBool = false): string|bool |
| | | { |
| | | $result = JVB_CONTENT[$content] ?? JVB_TAXONOMY[$content] ?? JVB_USER[$content] ?? ''; |
| | | return $returnBool ? ($result !== '') : $result; |
| | | } |
| | | |
| | | /** |
| | | * Check if user exists (cached) |
| | | */ |
| | | protected function checkUser(int $userId): bool |
| | | { |
| | | $cache = Cache::for('checkUser', DAY_IN_SECONDS)->connect('user'); |
| | | return $cache->remember($userId, fn() => (bool) get_userdata($userId)); |
| | | } |
| | | |
| | | /** |
| | | * Check if term exists (cached) |
| | | */ |
| | | protected function checkTerm(array $args): bool |
| | | { |
| | | $termId = $args['term_id'] ?? $args['to_term'] ?? false; |
| | | $taxonomy = $args['taxonomy'] ?? false; |
| | | |
| | | if (!$termId || !$taxonomy) { |
| | | return false; |
| | | } |
| | | |
| | | $cache = Cache::for('checkTerm', DAY_IN_SECONDS)->connect('taxonomy'); |
| | | return $cache->remember($termId, fn() => (bool) term_exists($termId, jvbCheckBase($taxonomy))); |
| | | } |
| | | |
| | | /** |
| | | * Check if user is verified |
| | | */ |
| | | protected function isVerifiedUser(int $userId): bool |
| | | { |
| | | $cache = Cache::for('verifiedUsers', DAY_IN_SECONDS)->connect('user'); |
| | | return $cache->remember($userId, fn() => user_can($userId, 'skip_moderation')); |
| | | } |
| | | |
| | | /** |
| | | * Sanitize array of IDs |
| | | */ |
| | | protected function sanitizeIds(array $ids): array |
| | | { |
| | | return array_values(array_filter(array_map('absint', $ids), fn($id) => $id > 0)); |
| | | } |
| | | |
| | | /** |
| | | * Get and validate meta values |
| | | */ |
| | | protected function getMetaValues(mixed $value): mixed |
| | | { |
| | | $decoded = is_string($value) ? json_decode($value, true) : $value; |
| | | |
| | | if (!is_array($decoded)) { |
| | | return $value; |
| | | } |
| | | |
| | | return array_map(fn($item) => is_object($item) ? (array) $item : $item, $decoded); |
| | | } |
| | | |
| | | /*************************************************************************** |
| | | * UTILITY |
| | | ***************************************************************************/ |
| | | protected function isTimeline($args, $data):bool |
| | | { |
| | | $post_types = is_array($args['post_type']) ? $args['post_type'] : [$args['post_type']]; |
| | | foreach ($post_types as $type) { |
| | | if (Features::forContent($type)->has('is_timeline')) { |
| | | return true; |
| | | } |
| | | } |
| | | return false; |
| | | } |
| | | // ========================================================================= |
| | | // SECURITY |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Verify Cloudflare Turnstile token |
| | | */ |
| | | protected function verifyTurnstile(string $token): bool |
| | | { |
| | | if (!Features::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) { |
| | | return true; |
| | | } |
| | | |
| | | return !empty($token) && JVB()->connect('cloudflare')->verifyTurnstile($token); |
| | | } |
| | | |
| | | /** |
| | | * Generate CSRF token for user |
| | | */ |
| | | protected function generateCsrfToken(int $userId): string |
| | | { |
| | | $token = wp_generate_password(32, false); |
| | | set_transient(BASE . 'csrf_' . $userId, $token, HOUR_IN_SECONDS); |
| | | return $token; |
| | | } |
| | | |
| | | /** |
| | | * Validate CSRF token from request header |
| | | */ |
| | | protected function validateCsrfToken(WP_REST_Request $request): bool |
| | | { |
| | | if (!is_user_logged_in() || in_array($request->get_method(), ['GET', 'HEAD', 'OPTIONS'])) { |
| | | return true; |
| | | } |
| | | |
| | | $userId = get_current_user_id(); |
| | | $token = $request->get_header('X-CSRF-Token'); |
| | | $stored = get_transient(BASE . 'csrf_' . $userId); |
| | | |
| | | return !empty($stored) && !empty($token) && hash_equals($stored, $token); |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // OPERATION LOCKING |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Prevent concurrent requests for the same operation |
| | | */ |
| | | protected function acquireOperationLock(string $key, int $duration = 5): bool |
| | | { |
| | | $lockKey = 'op_lock_' . md5($key); |
| | | |
| | | if (get_transient($lockKey)) { |
| | | return false; |
| | | } |
| | | |
| | | set_transient($lockKey, true, $duration); |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Release operation lock |
| | | */ |
| | | protected function releaseOperationLock(string $key): void |
| | | { |
| | | delete_transient('op_lock_' . md5($key)); |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // LOGGING |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Log security-relevant events |
| | | */ |
| | | protected function auditLog(string $event, array $data = []): void |
| | | { |
| | | $context = array_merge($data, [ |
| | | 'timestamp' => current_time('mysql'), |
| | | 'user_id' => get_current_user_id() ?: 0, |
| | | 'ip' => $_SERVER['REMOTE_ADDR'] ?? '', |
| | | 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '', |
| | | ]); |
| | | |
| | | try { |
| | | JVB()->error()->log('security_audit', $event, $context, 'info'); |
| | | } catch (Exception $e) { |
| | | error_log("Security Audit: {$event} - " . json_encode($context)); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Log errors with proper context |
| | | */ |
| | | protected function logError(string $message, array $context = [], string $severity = 'error'): void |
| | | { |
| | | try { |
| | | JVB()->error()->log(static::class, $message, $context, $severity); |
| | | } catch (Exception $e) { |
| | | error_log(static::class . " Error: {$message} - " . json_encode($context)); |
| | | } |
| | | } |
| | | |
| | | /************************************************************ |
| | | SESSION FINGERPRINT |
| | | ************************************************************/ |
| | | /** |
| | | * Store session fingerprint for hijacking detection |
| | | */ |
| | | protected function storeSessionFingerprint(int $user_id, WP_REST_Request $request): void |
| | | { |
| | | if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) { |
| | | return; |
| | | } |
| | | |
| | | $fingerprint = $this->generateSessionFingerprint($request); |
| | | update_user_meta($user_id, BASE . 'session_fingerprint', $fingerprint); |
| | | update_user_meta($user_id, BASE . 'session_timestamp', time()); |
| | | } |
| | | /** |
| | | * Generate session fingerprint for hijacking detection |
| | | * |
| | | * @param WP_REST_Request $request The REST request |
| | | * @return string Hashed fingerprint |
| | | */ |
| | | protected function generateSessionFingerprint(WP_REST_Request $request): string |
| | | { |
| | | return hash('sha256', implode('|', [ |
| | | $request->get_header('User-Agent') ?? '', |
| | | // Use IP class instead of full IP to allow for mobile network changes |
| | | $this->getIPClass( |
| | | $request->get_header('X-Forwarded-For') |
| | | ?: $request->get_header('X-Real-IP') |
| | | ?: $_SERVER['REMOTE_ADDR'] ?? '' |
| | | ) |
| | | ])); |
| | | } |
| | | |
| | | /** |
| | | * Get IP class (first 3 octets) for session validation |
| | | * This allows for minor IP changes (common with mobile networks) |
| | | * |
| | | * @param string $ip IP address |
| | | * @return string First 3 octets |
| | | */ |
| | | protected function getIPClass(string $ip): string |
| | | { |
| | | $parts = explode('.', $ip); |
| | | return implode('.', array_slice($parts, 0, 3)); |
| | | } |
| | | |
| | | /** |
| | | * Validate session fingerprint against stored value |
| | | * |
| | | * @param int $user_id User ID to validate |
| | | * @param WP_REST_Request $request Current request |
| | | * @return bool True if valid, false if potential hijacking |
| | | */ |
| | | protected function validateSessionFingerprint(int $user_id, WP_REST_Request $request): bool |
| | | { |
| | | // Only enforce if enabled in config |
| | | if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) { |
| | | return true; |
| | | } |
| | | |
| | | $stored = get_user_meta($user_id, BASE . 'session_fingerprint', true); |
| | | $current = $this->generateSessionFingerprint($request); |
| | | |
| | | if (empty($stored)) { |
| | | // First request - store fingerprint |
| | | update_user_meta($user_id, BASE . 'session_fingerprint', $current); |
| | | update_user_meta($user_id, BASE . 'session_timestamp', time()); |
| | | return true; |
| | | } |
| | | |
| | | // Compare using timing-safe comparison |
| | | return hash_equals($stored, $current); |
| | | } |
| | | |
| | | /** |
| | | * Clear session fingerprint (call on logout) |
| | | * |
| | | * @param int $user_id User ID |
| | | * @return void |
| | | */ |
| | | protected function clearSessionFingerprint(int $user_id): void |
| | | { |
| | | delete_user_meta($user_id, BASE . 'session_fingerprint'); |
| | | delete_user_meta($user_id, BASE . 'session_timestamp'); |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | /** |
| | | * @deprecated use Rest.php |
| | | * Handles route registration and high-level coordination |
| | | */ |
| | | abstract class RestRouteManager |
| | |
| | | protected string $route; |
| | | protected string $base; |
| | | protected string $content_type; //the registered post type |
| | | protected string $type; //post, user, term, for MetaManager |
| | | protected string $type; //post, user, term, for Meta |
| | | protected string $action = ''; //optional additional nonce to check |
| | | protected array $callback; //route->callback array |
| | | protected string $operation_type; // from QueueManager.js and OperationQueue.php |
| | |
| | | * Fluent REST Route Builder |
| | | * |
| | | * Usage: |
| | | * Route::get('queue', [$this, 'getQueue'])->auth('user')->args(['status' => 'string']); |
| | | * Route::resource('content')->get(...)->post(...)->delete(false); |
| | | * // Single-method routes |
| | | * Route::for('queue')->get([$this, 'getQueue'])->auth('user'); |
| | | * Route::for('content')->post([$this, 'create'])->auth('verified'); |
| | | * |
| | | * // Multi-method resources |
| | | * Route::for('uploads') |
| | | * ->get([$this, 'list'])->auth('user') |
| | | * ->post([$this, 'upload'])->auth('user') |
| | | * ->delete(false); |
| | | */ |
| | | class Route |
| | | { |
| | |
| | | private bool $registered = false; |
| | | |
| | | private static string $namespace = 'jvb/v1'; |
| | | private static ?RateLimiter $rateLimiter = null; |
| | | private static ?RateLimits $rateLimiter = null; |
| | | |
| | | // ========================================================================= |
| | | // ENTRY POINTS |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Create a resource route (supports multiple methods) |
| | | * Create a new route builder for the given path |
| | | */ |
| | | public static function resource(string $path): self |
| | | public static function for(string $path): self |
| | | { |
| | | return new self($path); |
| | | } |
| | | |
| | | /** |
| | | * Create a GET route |
| | | */ |
| | | public static function get(string $path, callable|array $callback): self |
| | | { |
| | | return (new self($path))->addMethod('GET', $callback); |
| | | } |
| | | |
| | | /** |
| | | * Create a POST route |
| | | */ |
| | | public static function post(string $path, callable|array $callback): self |
| | | { |
| | | return (new self($path))->addMethod('POST', $callback); |
| | | } |
| | | |
| | | /** |
| | | * Create a PUT route |
| | | */ |
| | | public static function put(string $path, callable|array $callback): self |
| | | { |
| | | return (new self($path))->addMethod('PUT', $callback); |
| | | } |
| | | |
| | | /** |
| | | * Create a PATCH route |
| | | */ |
| | | public static function patch(string $path, callable|array $callback): self |
| | | { |
| | | return (new self($path))->addMethod('PATCH', $callback); |
| | | } |
| | | |
| | | /** |
| | | * Create a DELETE route |
| | | */ |
| | | public static function delete(string $path, callable|array $callback): self |
| | | { |
| | | return (new self($path))->addMethod('DELETE', $callback); |
| | | } |
| | | |
| | | /** |
| | | * Set custom namespace (defaults to 'jvb/v1') |
| | | */ |
| | | public static function setNamespace(string $namespace): void |
| | |
| | | return self::$namespace; |
| | | } |
| | | |
| | | /** |
| | | * Convert readable pattern to WordPress regex |
| | | * Example: 'queue/{id}' becomes 'queue/(?P<id>[a-zA-Z0-9_-]+)' |
| | | */ |
| | | public static function pattern(string $path, array $patterns = []): string |
| | | { |
| | | $defaults = [ |
| | | 'id' => '[a-zA-Z0-9_-]+', |
| | | 'slug' => '[a-zA-Z0-9_-]+', |
| | | 'type' => '[a-zA-Z_]+', |
| | | 'int' => '[0-9]+', |
| | | ]; |
| | | |
| | | $patterns = array_merge($defaults, $patterns); |
| | | |
| | | return preg_replace_callback('/\{(\w+)(?::(\w+))?\}/', function($matches) use ($patterns) { |
| | | $name = $matches[1]; |
| | | $type = $matches[2] ?? $name; |
| | | $pattern = $patterns[$type] ?? $patterns['id']; |
| | | return "(?P<{$name}>{$pattern})"; |
| | | }, $path); |
| | | } |
| | | |
| | | private function __construct(string $path) |
| | | { |
| | | $this->path = '/' . ltrim($path, '/'); |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // HTTP METHODS |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Add GET method to resource |
| | | * Add GET handler |
| | | */ |
| | | public function get(callable|array $callback): self |
| | | { |
| | |
| | | } |
| | | |
| | | /** |
| | | * Add POST method to resource |
| | | * Add POST handler |
| | | */ |
| | | public function post(callable|array $callback): self |
| | | { |
| | |
| | | } |
| | | |
| | | /** |
| | | * Add PUT method to resource |
| | | * Add PUT handler |
| | | */ |
| | | public function put(callable|array $callback): self |
| | | { |
| | |
| | | } |
| | | |
| | | /** |
| | | * Add PATCH method to resource |
| | | * Add PATCH handler |
| | | */ |
| | | public function patch(callable|array $callback): self |
| | | { |
| | |
| | | } |
| | | |
| | | /** |
| | | * Add DELETE method to resource (pass false to explicitly disable) |
| | | * Add DELETE handler (pass false to explicitly disable) |
| | | */ |
| | | public function delete(callable|array|false $callback): self |
| | | { |
| | | if ($callback === false) { |
| | | return $this; // Explicitly disabled |
| | | return $this; |
| | | } |
| | | return $this->addMethod('DELETE', $callback); |
| | | } |
| | | |
| | | /** |
| | | * Internal method to add HTTP method |
| | | */ |
| | | private function addMethod(string $method, callable|array $callback): self |
| | | { |
| | | // Finalize previous method if exists |
| | |
| | | return $this; |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // AUTHENTICATION |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Set authentication/permission requirement |
| | | * Set authentication requirement |
| | | * |
| | | * @param string|array|false $auth |
| | | * @param string|array|callable|false $auth |
| | | * - 'public' or false: Anyone can access |
| | | * - 'user': Logged-in user must match 'user' param in request |
| | | * - 'logged_in': Any logged-in user |
| | | * - 'admin': Users with manage_options capability |
| | | * - 'verified': Users with skip_moderation capability |
| | | * - ['capability' => 'edit_posts']: Specific capability check |
| | | * - ['role' => 'artist']: Specific role check |
| | | * - ['capability' => 'edit_posts']: Specific capability |
| | | * - ['role' => 'artist']: Specific role |
| | | * - ['roles' => ['artist', 'admin']]: Multiple roles (OR) |
| | | * - callable: Custom permission callback |
| | | */ |
| | |
| | | $this->currentMethod['permission_callback'] = match (true) { |
| | | $auth === false || $auth === 'public' |
| | | => '__return_true', |
| | | |
| | | $auth === 'logged_in' |
| | | => 'is_user_logged_in', |
| | | |
| | | $auth === 'user' |
| | | => [PermissionHandler::class, 'userMatch'], |
| | | |
| | | $auth === 'admin' |
| | | => [PermissionHandler::class, 'isAdmin'], |
| | | |
| | | $auth === 'verified' |
| | | => [PermissionHandler::class, 'isVerified'], |
| | | |
| | | $auth === 'nonce' => [PermissionHandler::class, 'nonce'], |
| | | is_callable($auth) |
| | | => $auth, |
| | | |
| | | is_array($auth) && isset($auth['capability']) |
| | | => fn(WP_REST_Request $req) => current_user_can($auth['capability']), |
| | | |
| | | is_array($auth) && isset($auth['role']) |
| | | => fn(WP_REST_Request $req) => PermissionHandler::hasRole($req, $auth['role']), |
| | | |
| | | is_array($auth) && isset($auth['roles']) |
| | | => fn(WP_REST_Request $req) => PermissionHandler::hasAnyRole($req, $auth['roles']), |
| | | |
| | | default |
| | | => '__return_true', |
| | | }; |
| | |
| | | } |
| | | |
| | | /** |
| | | * Add rate limiting to the route |
| | | * |
| | | * @param int $limit Maximum requests |
| | | * @param int $window Time window in seconds |
| | | * Add rate limiting |
| | | */ |
| | | public function rateLimit(int $limit = 60, int $window = 60): self |
| | | { |
| | |
| | | $originalCallback = $this->currentMethod['permission_callback']; |
| | | |
| | | $this->currentMethod['permission_callback'] = function(WP_REST_Request $request) use ($originalCallback, $limit, $window) { |
| | | // Initialize rate limiter if needed |
| | | if (self::$rateLimiter === null) { |
| | | self::$rateLimiter = new RateLimiter(); |
| | | self::$rateLimiter = new RateLimits(); |
| | | } |
| | | |
| | | // Check rate limit first |
| | | if (!self::$rateLimiter->checkLimit($request, $limit, $window)) { |
| | | return new WP_Error( |
| | | 'rate_limit', |
| | |
| | | ); |
| | | } |
| | | |
| | | // Then check original permission |
| | | if ($originalCallback === '__return_true') { |
| | | return true; |
| | | } |
| | | |
| | | if (is_callable($originalCallback)) { |
| | | return call_user_func($originalCallback, $request); |
| | | } |
| | | |
| | | return true; |
| | | return is_callable($originalCallback) |
| | | ? call_user_func($originalCallback, $request) |
| | | : true; |
| | | }; |
| | | |
| | | return $this; |
| | |
| | | |
| | | /** |
| | | * Require nonce verification |
| | | * |
| | | * @param string $action Nonce action name (default: 'wp_rest') |
| | | * @param string $header Header name containing nonce (default: 'X-WP-Nonce') |
| | | */ |
| | | public function nonce(string $action = 'wp_rest', string $header = 'X-WP-Nonce'): self |
| | | { |
| | |
| | | ); |
| | | } |
| | | |
| | | // Then check original permission |
| | | if ($originalCallback === '__return_true') { |
| | | return true; |
| | | } |
| | | |
| | | if (is_callable($originalCallback)) { |
| | | return call_user_func($originalCallback, $request); |
| | | } |
| | | |
| | | return true; |
| | | return is_callable($originalCallback) |
| | | ? call_user_func($originalCallback, $request) |
| | | : true; |
| | | }; |
| | | |
| | | return $this; |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // ARGUMENTS |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Define route arguments with shorthand syntax |
| | | * |
| | | * @param array $args Argument definitions |
| | | * Shorthand: ['name' => 'type|required|default:value|enum:a,b,c'] |
| | | * Full: ['name' => ['type' => 'string', 'required' => true, ...]] |
| | | * |
| | | * Examples: |
| | | * 'status' => 'string' |
| | | * 'status' => 'string|required' |
| | | * 'status' => 'string|default:all' |
| | | * 'status' => 'string|enum:pending,completed,failed' |
| | | * 'limit' => 'integer|default:50|min:1|max:100' |
| | | * 'ids' => 'array|required' |
| | | */ |
| | | public function args(array $args): self |
| | | { |
| | |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Parse shorthand argument definition into WP REST format |
| | | */ |
| | | private function parseArgDefinition(string|array $definition): array |
| | | { |
| | | // Already full format |
| | | if (is_array($definition)) { |
| | | return $definition; |
| | | } |
| | |
| | | $type = trim($parts[0]); |
| | | |
| | | $arg = [ |
| | | 'type' => $type, |
| | | 'type' => $type === 'int' ? 'integer' : ($type === 'bool' ? 'boolean' : $type), |
| | | 'required' => false, |
| | | ]; |
| | | |
| | | // Add sanitize callback based on type |
| | | // Sanitize callback based on type |
| | | $arg['sanitize_callback'] = match ($type) { |
| | | 'integer', 'int' => 'absint', |
| | | 'string' => 'sanitize_text_field', |
| | |
| | | default => null, |
| | | }; |
| | | |
| | | // Normalize type for WP |
| | | if ($type === 'int') { |
| | | $arg['type'] = 'integer'; |
| | | } elseif ($type === 'bool') { |
| | | $arg['type'] = 'boolean'; |
| | | } |
| | | |
| | | // Parse modifiers |
| | | foreach (array_slice($parts, 1) as $part) { |
| | | $part = trim($part); |
| | | |
| | | if ($part === 'required') { |
| | | $arg['required'] = true; |
| | | } elseif (str_starts_with($part, 'default:')) { |
| | | $value = substr($part, 8); |
| | | $arg['default'] = $this->castValue($value, $type); |
| | | } elseif (str_starts_with($part, 'enum:')) { |
| | | $arg['enum'] = array_map('trim', explode(',', substr($part, 5))); |
| | | } elseif (str_starts_with($part, 'min:')) { |
| | | $arg['minimum'] = (int) substr($part, 4); |
| | | } elseif (str_starts_with($part, 'max:')) { |
| | | $arg['maximum'] = (int) substr($part, 4); |
| | | } elseif (str_starts_with($part, 'desc:')) { |
| | | $arg['description'] = substr($part, 5); |
| | | } elseif (str_starts_with($part, 'pattern:')) { |
| | | $arg['pattern'] = substr($part, 8); |
| | | } |
| | | match (true) { |
| | | $part === 'required' => $arg['required'] = true, |
| | | str_starts_with($part, 'default:') => $arg['default'] = $this->castValue(substr($part, 8), $type), |
| | | str_starts_with($part, 'enum:') => $arg['enum'] = array_map('trim', explode(',', substr($part, 5))), |
| | | str_starts_with($part, 'min:') => $arg['minimum'] = (int) substr($part, 4), |
| | | str_starts_with($part, 'max:') => $arg['maximum'] = (int) substr($part, 4), |
| | | str_starts_with($part, 'desc:') => $arg['description'] = substr($part, 5), |
| | | str_starts_with($part, 'pattern:') => $arg['pattern'] = substr($part, 8), |
| | | default => null, |
| | | }; |
| | | } |
| | | |
| | | // Remove null sanitize callback |
| | | if ($arg['sanitize_callback'] === null) { |
| | | unset($arg['sanitize_callback']); |
| | | } |
| | |
| | | return $arg; |
| | | } |
| | | |
| | | /** |
| | | * Cast value to appropriate type |
| | | */ |
| | | private function castValue(string $value, string $type): mixed |
| | | { |
| | | return match ($type) { |
| | |
| | | }; |
| | | } |
| | | |
| | | // ========================================================================= |
| | | // REGISTRATION |
| | | // ========================================================================= |
| | | |
| | | /** |
| | | * Register the route with WordPress |
| | | */ |
| | |
| | | return $this; |
| | | } |
| | | |
| | | // Add current method if not empty |
| | | if (!empty($this->currentMethod)) { |
| | | $this->methods[] = $this->currentMethod; |
| | | $this->currentMethod = []; |
| | |
| | | return $this; |
| | | } |
| | | |
| | | // Register single method or array of methods |
| | | $config = count($this->methods) === 1 ? $this->methods[0] : $this->methods; |
| | | |
| | | register_rest_route(self::$namespace, $this->path, $config); |
| | | |
| | | $this->registered = true; |
| | | |
| | | return $this; |
| | | } |
| | | |
| | |
| | | */ |
| | | public function __destruct() |
| | | { |
| | | if (!$this->registered && !empty($this->methods) || !empty($this->currentMethod)) { |
| | | if (!$this->registered && (!empty($this->methods) || !empty($this->currentMethod))) { |
| | | $this->register(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Convert WordPress route pattern to more readable format |
| | | * Converts: queue/{id} to queue/(?P<id>[a-zA-Z0-9_-]+) |
| | | */ |
| | | public static function pattern(string $path, array $patterns = []): string |
| | | { |
| | | $defaults = [ |
| | | 'id' => '[a-zA-Z0-9_-]+', |
| | | 'slug' => '[a-zA-Z0-9_-]+', |
| | | 'type' => '[a-zA-Z_]+', |
| | | 'int' => '[0-9]+', |
| | | ]; |
| | | |
| | | $patterns = array_merge($defaults, $patterns); |
| | | |
| | | return preg_replace_callback('/\{(\w+)(?::(\w+))?\}/', function($matches) use ($patterns) { |
| | | $name = $matches[1]; |
| | | $type = $matches[2] ?? $name; |
| | | $pattern = $patterns[$type] ?? $patterns['id']; |
| | | return "(?P<{$name}>{$pattern})"; |
| | | }, $path); |
| | | } |
| | | } |
| | |
| | | use JVBase\utility\Features; |
| | | |
| | | //NEW METHOD |
| | | //require(JVB_DIR . '/inc/rest/Route.php'); |
| | | //require(JVB_DIR . '/inc/rest/PermissionHandler.php'); |
| | | //require(JVB_DIR . '/inc/rest/Response.php'); |
| | | //require(JVB_DIR . '/inc/rest/Rest.php'); //Refactored RestRouteManager.php |
| | | //require(JVB_DIR . '/inc/rest/RateLimits.php'); |
| | | require(JVB_DIR . '/inc/rest/Route.php'); |
| | | require(JVB_DIR . '/inc/rest/PermissionHandler.php'); |
| | | require(JVB_DIR . '/inc/rest/Response.php'); |
| | | require(JVB_DIR . '/inc/rest/Rest.php'); |
| | | require(JVB_DIR . '/inc/rest/RateLimits.php'); |
| | | |
| | | //OLD METHOD |
| | | require(JVB_DIR . '/inc/rest/RateLimiter.php'); |
| | | require(JVB_DIR . '/inc/rest/RestRouteManager.php'); |
| | | require(JVB_DIR . '/inc/rest/RegisterRoutes.php'); |
| | | //require(JVB_DIR . '/inc/rest/RateLimiter.php'); |
| | | //require(JVB_DIR . '/inc/rest/RestRouteManager.php'); |
| | | //require(JVB_DIR . '/inc/rest/RegisterRoutes.php'); |
| | | |
| | | if (Features::forSite()->has('feed_block')) { |
| | | require(JVB_DIR . '/inc/rest/routes/FeedRoutes.php'); |
| | |
| | | require(JVB_DIR . '/inc/rest/routes/UploadRoutes.php'); |
| | | require(JVB_DIR . '/inc/rest/routes/SettingsRoutes.php'); |
| | | if (Features::forSite()->has('dashboard')) { |
| | | require(JVB_DIR . '/inc/rest/routes/AdminRoutes.php'); |
| | | // require(JVB_DIR . '/inc/rest/routes/AdminRoutes.php'); |
| | | require(JVB_DIR . '/inc/rest/routes/ContentRoutes.php'); |
| | | require(JVB_DIR . '/inc/rest/routes/BioRoutes.php'); |
| | | require(JVB_DIR . '/inc/rest/routes/ShopRoutes.php'); |
| | | // require(JVB_DIR . '/inc/rest/routes/BioRoutes.php'); |
| | | // require(JVB_DIR . '/inc/rest/routes/ShopRoutes.php'); |
| | | } |
| | | if (Features::forMembership()->has('forum')) { |
| | | require(JVB_DIR . '/inc/rest/routes/NewsRoutes.php'); |
| | |
| | | |
| | | namespace JVBase\rest\routes; |
| | | |
| | | use JVBase\JVB; |
| | | use JVBase\rest\RestRouteManager; |
| | | use JVBase\managers\Cache; |
| | | use JVBase\managers\CustomTable; |
| | | use JVBase\rest\PermissionHandler; |
| | | use JVBase\rest\Rest; |
| | | use JVBase\rest\Route; |
| | | use JVBase\rest\Response; |
| | | use JVBase\utility\Features; |
| | | use WP_User; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | use Exception; |
| | |
| | | exit; // Exit if accessed directly |
| | | } |
| | | |
| | | class ApprovalRoutes extends RestRouteManager |
| | | class ApprovalRoutes extends Rest |
| | | { |
| | | protected array $userTypes; |
| | | protected array $termTypes; |
| | | protected array $allTypes; |
| | | protected array $requestTables; |
| | | protected array $voteTables; |
| | | |
| | | protected int $expiryDays = 7; |
| | | protected bool $hasMemberApproval = false; |
| | | |
| | | public function __construct() |
| | | { |
| | | $this->cache_name = 'approvals'; |
| | | $this->cacheName = 'approvals'; |
| | | $this->hasMemberApproval = Features::forMembership()->has('member_verified'); |
| | | parent::__construct(); |
| | | |
| | |
| | | |
| | | public function registerRoutes():void |
| | | { |
| | | register_rest_route($this->namespace, '/approvals', [ |
| | | [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [ $this, 'getApprovals' ], |
| | | 'permission_callback' => [ $this, 'checkPermission' ] |
| | | ], |
| | | [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [ $this, 'handleApprovalAction' ], |
| | | 'permission_callback' => [ $this, 'checkPermission' ] |
| | | ] |
| | | ]); |
| | | Route::for('approvals') |
| | | ->get([$this, 'getApprovals']) |
| | | ->args([ |
| | | 'user' => 'integer|required', |
| | | 'type' => 'string', |
| | | 'status' => 'string|enum:pending,approved,rejected,expired', |
| | | ]) |
| | | ->auth(PermissionHandler::combine(['user', 'verified'])) |
| | | ->rateLimit(30) |
| | | ->post([$this, 'handleAction']) |
| | | ->args([ |
| | | 'user' => 'integer|required', |
| | | 'request_id' => 'integer|required', |
| | | 'action' => 'string|required|enum:approve,reject', |
| | | 'type' => 'string|required', |
| | | 'notes' => 'string', |
| | | ]) |
| | | ->auth(PermissionHandler::combine(['user', 'verified'])) |
| | | ->rateLimit(3); |
| | | } |
| | | |
| | | /** |
| | | * @param WP_REST_Request $request The REST request |
| | | * |
| | | * @return bool |
| | | */ |
| | | public function checkPermission(WP_REST_Request $request):bool |
| | | { |
| | | $userID = get_current_user_id(); |
| | | if (!user_can($userID, 'skip_moderation')) { |
| | | return false; |
| | | } |
| | | |
| | | return parent::checkPermission($request); |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Handler for user registration |
| | | * |
| | | * @param int $user_id New user ID |
| | |
| | | * |
| | | * @return void |
| | | */ |
| | | public function handleNewUserRegistration(int $user_id, object $user):void |
| | | { |
| | | public function handleNewUserRegistration(int $user_id, object $user): void |
| | | { |
| | | $intersect = array_intersect( |
| | | array_map( |
| | | function ($role) { |
| | | return BASE.$role; |
| | | }, |
| | | $this->userTypes |
| | | ), |
| | | array_map(fn($role) => BASE.$role, $this->userTypes), |
| | | (array) $user->roles |
| | | ); |
| | | if (!empty($intersect)) { |
| | | // Mark as unverified initially |
| | | $user->add_cap('skip_moderation', false); |
| | | // Create approval request |
| | | $this->createArtistApprovalRequest($user_id); |
| | | } |
| | | } |
| | | |
| | | if (!empty($intersect)) { |
| | | $user->add_cap('skip_moderation', false); |
| | | $this->createArtistApprovalRequest($user_id); |
| | | } |
| | | } |
| | | |
| | | |
| | | /** |
| | |
| | | * |
| | | * @return WP_REST_Response |
| | | */ |
| | | public function handleApprovalAction(WP_REST_Request $request):WP_REST_Response |
| | | public function handleAction(WP_REST_Request $request):WP_REST_Response |
| | | { |
| | | $data = $request->get_params(); |
| | | $request_id = $data['request_id'] ?? 0; |
| | | $user_id = (array_key_exists('user', $data) && |
| | | is_numeric($data['user'])) ? |
| | | (int) $data['user'] : get_current_user_id(); |
| | | $action = (array_key_exists('action', $data) && in_array($data['action'], [ |
| | | 'approve', |
| | | 'reject' |
| | | ])) ? $data['action'] : false; |
| | | $data = $request->get_params(); |
| | | $request_id = absint($data['request_id']); |
| | | $user_id = absint($data['user']); |
| | | $action = sanitize_text_field($data['action']); |
| | | $type = sanitize_text_field($data['type']); |
| | | $notes = sanitize_text_field($data['notes'] ?? ''); |
| | | |
| | | $type = (array_key_exists('type', $data) && |
| | | in_array($data['type'], $this->allTypes)) ? |
| | | $data['type'] : |
| | | false; |
| | | $notes = (array_key_exists('notes', $data)) ? sanitize_text_field($data['notes']) : ''; |
| | | if (!in_array($type, $this->allTypes)) { |
| | | return Response::validationError(['message' => 'Invalid type']); |
| | | } |
| | | |
| | | if ($action && $request_id !== 0 && $type) { |
| | | $result = $this->handleVote($type, $action, $request_id, $user_id, $notes); |
| | | return new WP_REST_Response([ |
| | | 'success' => $result, |
| | | 'message' => $result ? 'Vote recorded successfully' : 'Failed to record vote' |
| | | ], $result ? 200 : 500); |
| | | } |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Invalid action or request ID' |
| | | ], 400); |
| | | $result = $this->handleVote($type, $action, $request_id, $user_id, $notes); |
| | | |
| | | return $result |
| | | ? Response::success(['message' => 'Vote recorded successfully']) |
| | | : Response::error('Failed to record vote'); |
| | | } |
| | | |
| | | protected function getRequestTable(string $type, string $prefix):string |
| | | { |
| | | return match ($type) { |
| | | 'term' => $prefix . BASE . 'approval_term_requests', |
| | | default => $prefix . BASE . 'approval_' . $type . '_requests', |
| | | }; |
| | | } |
| | | protected function getVoteTable(string $type, string $prefix):string |
| | | { |
| | | return match ($type) { |
| | | 'term' => $prefix . BASE . 'approval_term_votes', |
| | | default => $prefix . BASE . 'approval_' . $type . '_votes', |
| | | }; |
| | | } |
| | | /** |
| | | * Artist and Term Approvals |
| | | */ |
| | | protected function handleVote(string $type, string $vote, int $request_id, int $user_id, string $notes = ''):bool |
| | | { |
| | | if (!in_array($vote, ['approve', 'reject'])) { |
| | | return false; |
| | | } |
| | | global $wpdb; |
| | | $table = $this->getRequestTable($type, $wpdb->prefix); |
| | | $votes = $this->getVoteTable($type, $wpdb->prefix); |
| | | protected function handleVote(string $type, string $vote, int $request_id, int $user_id, string $notes = ''): bool |
| | | { |
| | | if (!in_array($vote, ['approve', 'reject'])) { |
| | | return false; |
| | | } |
| | | |
| | | $requestTable = $this->getTableName($type, 'requests'); |
| | | $voteTable = $this->getTableName($type, 'votes'); |
| | | |
| | | try { |
| | | $request = $wpdb->get_row($wpdb->prepare( |
| | | "SELECT * FROM $table WHERE id = %d", |
| | | $request_id |
| | | )); |
| | | if (!$request || $request->status !== 'pending') { |
| | | throw new Exception("Invalid approval request"); |
| | | } |
| | | $requests = CustomTable::for($requestTable); |
| | | $votes = CustomTable::for($voteTable); |
| | | |
| | | $already_voted = $wpdb->get_row($wpdb->prepare( |
| | | "SELECT * FROM $votes WHERE request_id = %d AND user_id = %d", |
| | | $request_id, |
| | | $user_id |
| | | )); |
| | | try { |
| | | return $requests->transaction(function($requests) use ($votes, $request_id, $user_id, $vote, $notes, $type) { |
| | | // Get the approval request |
| | | $request = $requests->where(['id' => $request_id])->first(); |
| | | |
| | | if ($already_voted && $already_voted->vote !== $vote) { |
| | | $wpdb->update( |
| | | $votes, |
| | | [ |
| | | 'vote' => $vote, |
| | | ], |
| | | [ |
| | | 'id' => $already_voted->id |
| | | ] |
| | | ); |
| | | return true; |
| | | } elseif ($already_voted) { |
| | | throw new Exception("User has already voted on this request"); |
| | | } |
| | | if (!$request || $request->status !== 'pending') { |
| | | throw new Exception("Invalid approval request"); |
| | | } |
| | | |
| | | $result = $wpdb->insert( |
| | | $votes, |
| | | [ |
| | | 'request_id' => $request_id, |
| | | 'user_id' => $user_id, |
| | | 'vote' => $vote, |
| | | 'notes' => $notes, |
| | | 'created_at' => current_time('mysql') |
| | | ] |
| | | ); |
| | | if (!$result) { |
| | | throw new Exception("Failed to record vote"); |
| | | } |
| | | // Check if user already voted |
| | | $existingVote = $votes->where([ |
| | | 'request_id' => $request_id, |
| | | 'user_id' => $user_id |
| | | ])->first(); |
| | | |
| | | $user = get_userdata($user_id); |
| | | if ($vote === 'approve') { |
| | | $approvers = json_decode($request->approved_by, true)?:[]; |
| | | if ($existingVote) { |
| | | if ($existingVote->vote !== $vote) { |
| | | // Update vote |
| | | $votes->where(['id' => $existingVote->id]) |
| | | ->updateResults(['vote' => $vote]); |
| | | return true; |
| | | } |
| | | throw new Exception("User has already voted on this request"); |
| | | } |
| | | |
| | | $approvers[$user_id] = [ |
| | | 'name' => $user->display_name, |
| | | 'voted' => current_time('mysql') |
| | | ]; |
| | | $wpdb->update( |
| | | $table, |
| | | [ |
| | | 'current_approvals' => $request->current_approvals + 1, |
| | | 'updated_at' => current_time('mysql'), |
| | | 'approved_by' => $approvers, |
| | | 'expires_at' => $this->rebuildExpiryDate() |
| | | ], |
| | | [ |
| | | 'id' => $request_id |
| | | ] |
| | | ); |
| | | if ($request->current_approvals + 1 >= $request->required_approvals) { |
| | | switch ($type) { |
| | | case 'user': |
| | | case 'artist': |
| | | $this->completeVerification($request_id); |
| | | break; |
| | | case 'term': |
| | | $this->makeTermLive($request); |
| | | break; |
| | | } |
| | | } |
| | | } elseif ($vote === 'reject') { |
| | | $rejecters = json_decode($request->rejected_by, true)?:[]; |
| | | // Insert new vote |
| | | $votes->create([ |
| | | 'request_id' => $request_id, |
| | | 'user_id' => $user_id, |
| | | 'vote' => $vote, |
| | | 'notes' => $notes, |
| | | ]); |
| | | |
| | | $rejecters[$user_id] = [ |
| | | 'name' => $user->display_name, |
| | | 'voted' => current_time('mysql') |
| | | ]; |
| | | $wpdb->update( |
| | | $table, |
| | | [ |
| | | 'current_rejections' => $request->current_rejections + 1, |
| | | 'rejected_by' => $rejecters, |
| | | 'updated_at' => current_time('mysql'), |
| | | 'expires_at' => $this->rebuildExpiryDate() |
| | | ], |
| | | [ |
| | | 'id' => $request_id |
| | | ] |
| | | ); |
| | | if ($request->current_rejections + 1 >= $request->required_approvals) { |
| | | switch ($type) { |
| | | case 'user': |
| | | case 'artist': |
| | | $this->denyVerification($request_id); |
| | | break; |
| | | case 'term': |
| | | $this->makeTermUnalive($request); |
| | | break; |
| | | } |
| | | } |
| | | } |
| | | // Update request based on vote type |
| | | $user = get_userdata($user_id); |
| | | |
| | | $wpdb->query('COMMIT'); |
| | | if ($vote === 'approve') { |
| | | $this->handleApproval($requests, $request, $request_id, $user, $type); |
| | | } else { |
| | | $this->handleRejection($requests, $request, $request_id, $user, $type); |
| | | } |
| | | |
| | | return true; |
| | | } catch (Exception $e) { |
| | | $wpdb->query('ROLLBACK'); |
| | | return true; |
| | | }); |
| | | } catch (Exception $e) { |
| | | $this->logError('handleVote', [ |
| | | 'error' => $e->getMessage(), |
| | | 'user_id' => $user_id, |
| | | 'request_id' => $request_id, |
| | | 'vote' => $vote |
| | | ]); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | JVB()->error() |
| | | ->log( |
| | | '[ApprovalRoutes]:handleVote', |
| | | "Error creating '.$type.' approval request: " . $e->getMessage(), |
| | | [ |
| | | 'user_id' => $user_id, |
| | | 'request_id' => $request_id, |
| | | 'vote' => $vote |
| | | ] |
| | | ); |
| | | return false; |
| | | } |
| | | } |
| | | /** |
| | | * Handle approval vote logic |
| | | */ |
| | | protected function handleApproval(CustomTable $table, object $request, int $request_id, $user, string $type): void |
| | | { |
| | | $approvers = json_decode($request->approved_by, true) ?: []; |
| | | $approvers[$user->ID] = [ |
| | | 'name' => $user->display_name, |
| | | 'voted' => current_time('mysql') |
| | | ]; |
| | | |
| | | $table->where(['id' => $request_id])->updateResults([ |
| | | 'current_approvals' => $request->current_approvals + 1, |
| | | 'approved_by' => json_encode($approvers), |
| | | 'expires_at' => $this->rebuildExpiryDate() |
| | | ]); |
| | | |
| | | // Check if threshold met |
| | | if ($request->current_approvals + 1 >= $request->required_approvals) { |
| | | match ($type) { |
| | | 'term' => $this->makeTermLive($request), |
| | | default => $this->completeVerification($request_id, $type), |
| | | }; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle rejection vote logic |
| | | */ |
| | | protected function handleRejection(CustomTable $table, object $request, int $request_id, $user, string $type): void |
| | | { |
| | | $rejecters = json_decode($request->rejected_by, true) ?: []; |
| | | $rejecters[$user->ID] = [ |
| | | 'name' => $user->display_name, |
| | | 'voted' => current_time('mysql') |
| | | ]; |
| | | |
| | | $table->where(['id' => $request_id])->updateResults([ |
| | | 'current_rejections' => $request->current_rejections + 1, |
| | | 'rejected_by' => json_encode($rejecters), |
| | | 'expires_at' => $this->rebuildExpiryDate() |
| | | ]); |
| | | |
| | | // Check if threshold met |
| | | if ($request->current_rejections + 1 >= $request->required_approvals) { |
| | | match ($type) { |
| | | 'term' => $this->makeTermUnalive($request), |
| | | default => $this->denyVerification($request_id, $type), |
| | | }; |
| | | } |
| | | } |
| | | protected function rebuildExpiryDate() |
| | | { |
| | | return date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days", time())); |
| | | } |
| | | |
| | | /** |
| | | * @param string $type user/artist or term |
| | | * @param array $request |
| | | * |
| | | * @return bool|int |
| | | */ |
| | | protected function createApprovalRequest(string $type, array $request):bool|int |
| | | { |
| | | global $wpdb; |
| | | protected function createApprovalRequest(string $type, array $request): int |
| | | { |
| | | $tableName = $this->getTableName($type, 'requests'); |
| | | |
| | | $table = $this->getRequestTable($type, $wpdb->prefix); |
| | | $id = CustomTable::for($tableName)->create($request); |
| | | |
| | | $result = $wpdb->insert( |
| | | $table, |
| | | $request |
| | | ); |
| | | if (!$id) { |
| | | throw new Exception('Failed to create approval request'); |
| | | } |
| | | |
| | | if (!$result) { |
| | | throw new Exception($wpdb->last_error); |
| | | } |
| | | return $wpdb->insert_id; |
| | | } |
| | | return $id; |
| | | } |
| | | |
| | | /************* |
| | | * Artist Approvals |
| | |
| | | * |
| | | * @return int|false Request ID or false on failure |
| | | */ |
| | | public function createArtistApprovalRequest(int $user_id):int|false |
| | | { |
| | | global $wpdb; |
| | | $wpdb->query('START TRANSACTION'); |
| | | /** |
| | | * Create artist approval request - REFACTORED |
| | | */ |
| | | public function createArtistApprovalRequest(int $user_id): int|false |
| | | { |
| | | $userRole = jvbUserRole($user_id); |
| | | $tableName = $this->getTableName($userRole, 'requests'); |
| | | $table = CustomTable::for($tableName); |
| | | |
| | | try { |
| | | //Check for existing first |
| | | $table = $this->getRequestTable(jvbUserRole($user_id), $wpdb->prefix); |
| | | try { |
| | | return $table->transaction(function($table) use ($user_id) { |
| | | // Check for existing request |
| | | $existing = $table->where(['user_id' => $user_id])->first(); |
| | | |
| | | // Verify this is not a duplicate request |
| | | $existing = $wpdb->get_var($wpdb->prepare( |
| | | "SELECT id FROM $table |
| | | WHERE user_id = %d", |
| | | $user_id |
| | | )); |
| | | if ($existing) { |
| | | return $existing->id; |
| | | } |
| | | |
| | | if ($existing) { |
| | | return $existing; |
| | | } |
| | | $user_data = get_userdata($user_id); |
| | | |
| | | $user_data = get_userdata($user_id); |
| | | $request = [ |
| | | 'user_id' => $user_id, |
| | | 'status' => 'pending', |
| | | 'expires_at' => date('Y-m-d H:i:s', strtotime('+30 days')), |
| | | 'created_at' => current_time('mysql'), |
| | | 'updated_at' => current_time('mysql'), |
| | | 'name' => $user_data->display_name, |
| | | 'email' => $user_data->user_email, |
| | | ]; |
| | | |
| | | $result = $this->createApprovalRequest('user', $request); |
| | | |
| | | if (!$result) { |
| | | throw new Exception($wpdb->last_error); |
| | | } |
| | | |
| | | $wpdb->query('COMMIT'); |
| | | |
| | | return $result; |
| | | } catch (Exception $e) { |
| | | $wpdb->query('ROLLBACK'); |
| | | JVB()->error() |
| | | ->log( |
| | | '[ApprovalRoutes]:createArtistApprovalRequest', |
| | | "Error creating artist approval request: " . $e->getMessage(), |
| | | [ |
| | | 'user_id' => $user_id, |
| | | ] |
| | | ); |
| | | |
| | | return false; |
| | | } |
| | | } |
| | | return $table->create([ |
| | | 'user_id' => $user_id, |
| | | 'status' => 'pending', |
| | | 'expires_at' => date('Y-m-d H:i:s', strtotime('+30 days')), |
| | | 'current_approvals' => 0, |
| | | 'current_rejections' => 0, |
| | | 'required_approvals' => 3, // From config |
| | | 'approved_by' => json_encode([]), |
| | | 'rejected_by' => json_encode([]), |
| | | ]); |
| | | }); |
| | | } catch (Exception $e) { |
| | | $this->logError('createArtistApprovalRequest', [ |
| | | 'error' => $e->getMessage(), |
| | | 'user_id' => $user_id |
| | | ]); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Mark an artist as verified |
| | |
| | | return $this->handleVote(jvbUserRole($user_id), $vote, $request_id, $user_id, $notes); |
| | | } |
| | | |
| | | /** |
| | | * Mark an artist as verified after receiving required approvals |
| | | * |
| | | * @param int $request_id The approval request ID |
| | | * |
| | | * @return bool Success status |
| | | */ |
| | | public function completeVerification(int $request_id):bool |
| | | { |
| | | global $wpdb; |
| | | $approval_table = $wpdb->prefix . $this->userRequests; |
| | | /** |
| | | * Complete verification - REFACTORED |
| | | */ |
| | | protected function completeVerification(int $request_id, string $type = 'artist'): void |
| | | { |
| | | $tableName = $this->getTableName($type, 'requests'); |
| | | $table = CustomTable::for($tableName); |
| | | |
| | | // Get the request details |
| | | $request = $wpdb->get_row($wpdb->prepare( |
| | | "SELECT * FROM $approval_table WHERE id = %d", |
| | | $request_id |
| | | )); |
| | | $table->where(['id' => $request_id])->updateResults([ |
| | | 'status' => 'approved', |
| | | 'approved_at' => current_time('mysql') |
| | | ]); |
| | | |
| | | if (!$request || $request->status !== 'pending') { |
| | | return false; |
| | | } |
| | | $request = $table->where(['id' => $request_id])->first(); |
| | | |
| | | // Check if enough approvals have been collected |
| | | if ($request->current_approvals < $request->required_approvals) { |
| | | return false; |
| | | } |
| | | if ($request && $request->user_id) { |
| | | $user = new \WP_User($request->user_id); |
| | | $user->add_cap('skip_moderation', true); |
| | | |
| | | // Start a transaction |
| | | $wpdb->query('START TRANSACTION'); |
| | | JVB()->notification()->addNotification( |
| | | $request->user_id, |
| | | 'approval_granted', |
| | | ['message' => 'Your account has been verified!'] |
| | | ); |
| | | } |
| | | |
| | | try { |
| | | // Get the user ID from the request |
| | | $user_id = $request->user_id; |
| | | $this->cache->flush(); |
| | | } |
| | | |
| | | $this->verifyArtist($user_id, $request->current_approvals); |
| | | protected function denyVerification(int $request_id, string $type = 'artist'): void |
| | | { |
| | | $tableName = $this->getTableName($type, 'requests'); |
| | | $table = CustomTable::for($tableName); |
| | | |
| | | // Update the request status |
| | | $updated = $wpdb->update( |
| | | $approval_table, |
| | | [ |
| | | 'status' => 'approved', |
| | | 'updated_at' => current_time('mysql') |
| | | ], |
| | | [ 'id' => $request_id ] |
| | | ); |
| | | $table->where(['id' => $request_id])->updateResults([ |
| | | 'status' => 'rejected', |
| | | 'rejected_at' => current_time('mysql') |
| | | ]); |
| | | |
| | | if ($updated === false) { |
| | | throw new Exception("Failed to update approval request status"); |
| | | } |
| | | $request = $table->where(['id' => $request_id])->first(); |
| | | |
| | | // Notify the user they've been verified |
| | | JVB()->notification()->addNotification( |
| | | $user_id, |
| | | 'artist_approved', |
| | | [ |
| | | 'request_id' => $request_id, |
| | | 'approval_date' => current_time('mysql') |
| | | ] |
| | | ); |
| | | if ($request && $request->user_id) { |
| | | JVB()->notification()->addNotification( |
| | | $request->user_id, |
| | | 'approval_denied', |
| | | ['message' => 'Your verification request was not approved.'] |
| | | ); |
| | | } |
| | | |
| | | $wpdb->query('COMMIT'); |
| | | |
| | | return true; |
| | | } catch (Exception $e) { |
| | | $wpdb->query('ROLLBACK'); |
| | | JVB()->error() |
| | | ->log( |
| | | '[ApprovalRoutes]:completeVerification', |
| | | "Error verifying user: " . $e->getMessage(), |
| | | [ |
| | | 'user_id' => $user_id, |
| | | ] |
| | | ); |
| | | |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | public function denyVerification(int $request_id):bool |
| | | { |
| | | global $wpdb; |
| | | $approval_table = $wpdb->prefix . $this->userRequests; |
| | | |
| | | // Get the request details |
| | | $request = $wpdb->get_row($wpdb->prepare( |
| | | "SELECT * FROM $approval_table WHERE id = %d", |
| | | $request_id |
| | | )); |
| | | |
| | | if (!$request || $request->status !== 'pending') { |
| | | return false; |
| | | } |
| | | |
| | | // Check if enough approvals have been collected |
| | | if ($request->current_rejections < $request->required_approvals) { |
| | | return false; |
| | | } |
| | | |
| | | // Start a transaction |
| | | $wpdb->query('START TRANSACTION'); |
| | | |
| | | try { |
| | | // Get the user ID from the request |
| | | $user_id = $request->user_id; |
| | | |
| | | $this->unverifyArtist($user_id, $request->rejected_by); |
| | | |
| | | // Update the request status |
| | | $updated = $wpdb->update( |
| | | $approval_table, |
| | | [ |
| | | 'status' => 'rejected', |
| | | 'updated_at' => current_time('mysql') |
| | | ], |
| | | [ 'id' => $request_id ] |
| | | ); |
| | | |
| | | if ($updated === false) { |
| | | throw new Exception("Failed to update approval request status"); |
| | | } |
| | | |
| | | // Notify the user they've been verified |
| | | JVB()->notification()->addNotification( |
| | | $user_id, |
| | | 'artist_rejected', |
| | | [ |
| | | 'request_id' => $request_id, |
| | | 'approval_date' => current_time('mysql') |
| | | ] |
| | | ); |
| | | |
| | | $wpdb->query('COMMIT'); |
| | | |
| | | return true; |
| | | } catch (Exception $e) { |
| | | $wpdb->query('ROLLBACK'); |
| | | JVB()->error() |
| | | ->log( |
| | | '[ApprovalRoutes]:denyVerification', |
| | | "Error removing artist verification status: " . $e->getMessage(), |
| | | [ |
| | | 'user_id' => $user_id |
| | | ] |
| | | ); |
| | | |
| | | return false; |
| | | } |
| | | } |
| | | $this->cache->flush(); |
| | | } |
| | | |
| | | /** |
| | | * Get verification details for a request |
| | |
| | | * |
| | | * @return array|false Verification details or false if not verified |
| | | */ |
| | | public function getVerificationDetails(int $requestID, string $type):array|false |
| | | { |
| | | global $wpdb; |
| | | public function getVerificationDetails(int $requestID, string $type): array|false |
| | | { |
| | | $requestTable = CustomTable::for($this->getTableName($type, 'requests')); |
| | | $voteTable = CustomTable::for($this->getTableName($type, 'votes')); |
| | | |
| | | $approval_table = $this->getRequestTable($type, $wpdb->prefix); |
| | | $votes_table = $this->getVoteTable($type, $wpdb->prefix); |
| | | $request = $requestTable->where(['id' => $requestID])->first(ARRAY_A); |
| | | |
| | | // Get the approval request |
| | | $request = $wpdb->get_row($wpdb->prepare( |
| | | "SELECT * FROM $approval_table |
| | | WHERE id = %d |
| | | ORDER BY updated_at DESC", |
| | | $requestID |
| | | ), ARRAY_A); |
| | | if (!$request) { |
| | | return false; |
| | | } |
| | | |
| | | if (!$request) { |
| | | return false; |
| | | } |
| | | // Get the votes for this request |
| | | $votes = $voteTable |
| | | ->where(['request_id' => $request['id']]) |
| | | ->orderBy('created_at', 'ASC') |
| | | ->getResults(ARRAY_A); |
| | | |
| | | // Get the votes for this request |
| | | $votes = $wpdb->get_results($wpdb->prepare( |
| | | "SELECT v.*, u.display_name as approver_name |
| | | FROM $votes_table v |
| | | WHERE v.request_id = %d |
| | | ORDER BY v.created_at", |
| | | $request['id'] |
| | | ), ARRAY_A); |
| | | // Join with user data for display names |
| | | foreach ($votes as &$vote) { |
| | | $user = get_userdata($vote['user_id']); |
| | | $vote['approver_name'] = $user ? $user->display_name : 'Unknown'; |
| | | } |
| | | |
| | | return [ |
| | | 'request' => $request, |
| | | 'votes' => $votes, |
| | | 'verification_date' => $request['updated_at'], |
| | | ]; |
| | | } |
| | | return [ |
| | | 'request' => $request, |
| | | 'votes' => $votes, |
| | | 'verification_date' => $request['updated_at'], |
| | | ]; |
| | | } |
| | | |
| | | /************* |
| | | * Term Approvals |
| | |
| | | * |
| | | * @return boolean Success or failure |
| | | */ |
| | | protected function makeTermLive(object $request):bool |
| | | { |
| | | global $wpdb; |
| | | protected function makeTermLive(object $request): bool |
| | | { |
| | | try { |
| | | $taxonomy = $request->taxonomy; |
| | | $term_name = $request->name; |
| | | $parent = $request->parent; |
| | | |
| | | try { |
| | | // Get term data from request |
| | | $taxonomy = $request->taxonomy; |
| | | $term_name = $request->name; |
| | | $parent = $request->parent; |
| | | $result = wp_insert_term($term_name, $taxonomy, [ |
| | | 'parent' => $parent |
| | | ]); |
| | | |
| | | $result = wp_insert_term($term_name, $taxonomy, [ |
| | | 'parent' => $parent |
| | | ]); |
| | | if (is_wp_error($result)) { |
| | | throw new Exception($result->get_error_message()); |
| | | } |
| | | |
| | | if (is_wp_error($result)) { |
| | | throw new Exception($result->get_error_message()); |
| | | } |
| | | $term_id = $result['term_id']; |
| | | $term_id = $result['term_id']; |
| | | |
| | | $table = $this->getRequestTable('term', $wpdb); |
| | | // Update request status |
| | | $wpdb->update( |
| | | $table, |
| | | [ |
| | | 'status' => 'approved', |
| | | 'updated_at' => current_time('mysql'), |
| | | 'created_term' => $term_id |
| | | ], |
| | | [ 'id' => $request->id ] |
| | | ); |
| | | // Update request status |
| | | CustomTable::for($this->getTableName('term', 'requests')) |
| | | ->where(['id' => $request->id]) |
| | | ->updateResults([ |
| | | 'status' => 'approved', |
| | | 'created_term' => $term_id |
| | | ]); |
| | | |
| | | $userIDs = []; |
| | | $approvedBy = []; |
| | | $approvors = json_decode($request->approved_by, true) ?: []; |
| | | $requesters = json_decode($request->requested_by, true) ?: []; |
| | | $rejectors = json_decode($request->rejected_by, true) ?: []; |
| | | foreach (array_merge($requesters, $approvors, $rejectors) as $user_id => $info) { |
| | | $userIDs[] = $user_id; |
| | | } |
| | | foreach ($approvors as $user_id => $info) { |
| | | $approvedBy[] = $info['name']; |
| | | } |
| | | $userIDs = []; |
| | | $approvedBy = []; |
| | | $approvers = json_decode($request->approved_by, true) ?: []; |
| | | $requesters = json_decode($request->requested_by, true) ?: []; |
| | | $rejectors = json_decode($request->rejected_by, true) ?: []; |
| | | |
| | | $approvedBy = jvbCommaList($approvedBy); |
| | | foreach (array_merge($requesters, $approvers, $rejectors) as $user_id => $info) { |
| | | $userIDs[] = $user_id; |
| | | } |
| | | foreach ($approvers as $user_id => $info) { |
| | | $approvedBy[] = $info['name']; |
| | | } |
| | | |
| | | // Notify the requester |
| | | JVB()->notification()->addNotification( |
| | | $userIDs, |
| | | 'term_approved', |
| | | [ |
| | | 'term_id' => $term_id, |
| | | 'term_name' => $term_name, |
| | | 'taxonomy' => $taxonomy, |
| | | 'approved_by' => $approvedBy |
| | | ] |
| | | ); |
| | | $approvedBy = jvbCommaList($approvedBy); |
| | | |
| | | return true; |
| | | } catch (Exception $e) { |
| | | JVB()->error() |
| | | ->log( |
| | | '[ApprovalRoutes]:makeTermLive', |
| | | "Error making term live: " . $e->getMessage(), |
| | | [ |
| | | 'request_id' => $request->id, |
| | | 'requester' => $request->requested_by, |
| | | 'term_name' => $term_name, |
| | | 'taxonomy' => $taxonomy |
| | | ] |
| | | ); |
| | | JVB()->notification()->addNotification( |
| | | $userIDs, |
| | | 'term_approved', |
| | | [ |
| | | 'term_id' => $term_id, |
| | | 'term_name' => $term_name, |
| | | 'taxonomy' => $taxonomy, |
| | | 'approved_by' => $approvedBy |
| | | ] |
| | | ); |
| | | |
| | | return false; |
| | | } |
| | | } |
| | | return true; |
| | | } catch (Exception $e) { |
| | | $this->logError('makeTermLive', [ |
| | | 'error' => $e->getMessage(), |
| | | 'request_id' => $request->id, |
| | | 'term_name' => $term_name ?? '', |
| | | 'taxonomy' => $taxonomy ?? '' |
| | | ]); |
| | | |
| | | return false; |
| | | } |
| | | } |
| | | /** |
| | | * Reject a proposed term |
| | | * |
| | |
| | | * |
| | | * @return boolean Success or failure |
| | | */ |
| | | protected function makeTermUnalive(object $request):bool |
| | | { |
| | | global $wpdb; |
| | | protected function makeTermUnalive(object $request): bool |
| | | { |
| | | try { |
| | | // Update request status |
| | | CustomTable::for($this->getTableName('term', 'requests')) |
| | | ->where(['id' => $request->id]) |
| | | ->updateResults([ |
| | | 'status' => 'rejected' |
| | | ]); |
| | | |
| | | try { |
| | | // Update request status |
| | | $wpdb->update( |
| | | $this->getRequestTable('term', $wpdb), |
| | | [ |
| | | 'status' => 'rejected', |
| | | 'updated_at' => current_time('mysql'), |
| | | ], |
| | | [ 'id' => $request->id ] |
| | | ); |
| | | $userIDs = []; |
| | | $rejectedBy = []; |
| | | |
| | | $userIDs = []; |
| | | $rejectedBy = []; |
| | | $approvers = json_decode($request->approved_by, true) ?: []; |
| | | $requesters = json_decode($request->requested_by, true) ?: []; |
| | | $rejectors = json_decode($request->rejected_by, true) ?: []; |
| | | |
| | | $approvors = json_decode($request->approved_by, true) ?: []; |
| | | $requesters = json_decode($request->requested_by, true) ?: []; |
| | | $rejectors = json_decode($request->rejected_by, true) ?: []; |
| | | foreach (array_merge($requesters, $approvors, $rejectors) as $user_id => $info) { |
| | | $userIDs[] = $user_id; |
| | | } |
| | | foreach ($rejectors as $user_id => $info) { |
| | | $rejectedBy[] = $info['name']; |
| | | } |
| | | foreach (array_merge($requesters, $approvers, $rejectors) as $user_id => $info) { |
| | | $userIDs[] = $user_id; |
| | | } |
| | | foreach ($rejectors as $user_id => $info) { |
| | | $rejectedBy[] = $info['name']; |
| | | } |
| | | |
| | | $rejectedBy = jvbCommaList($rejectedBy); |
| | | $rejectedBy = jvbCommaList($rejectedBy); |
| | | |
| | | // Notify the requester |
| | | JVB()->notification()->addNotification( |
| | | $userIDs, |
| | | 'term_rejected', |
| | | [ |
| | | 'term_name' => $request->name, |
| | | 'taxonomy' => $request->taxonomy, |
| | | 'rejected_by' => $rejectedBy |
| | | ] |
| | | ); |
| | | JVB()->notification()->addNotification( |
| | | $userIDs, |
| | | 'term_rejected', |
| | | [ |
| | | 'term_name' => $request->name, |
| | | 'taxonomy' => $request->taxonomy, |
| | | 'rejected_by' => $rejectedBy |
| | | ] |
| | | ); |
| | | |
| | | return true; |
| | | } catch (Exception $e) { |
| | | JVB()->error() |
| | | ->log( |
| | | '[ApprovalRoutes]:makeTermUnalive', |
| | | "Error rejecting term: " . $e->getMessage(), |
| | | [ |
| | | 'request_id' => $request->id, |
| | | 'requester' => $request->requested_by, |
| | | 'term_name' => $request->name, |
| | | 'taxonomy' => $request->taxonomy |
| | | ] |
| | | ); |
| | | return true; |
| | | } catch (Exception $e) { |
| | | $this->logError('makeTermUnalive', [ |
| | | 'error' => $e->getMessage(), |
| | | 'request_id' => $request->id, |
| | | 'term_name' => $request->name ?? '', |
| | | 'taxonomy' => $request->taxonomy ?? '' |
| | | ]); |
| | | |
| | | return false; |
| | | } |
| | | } |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Create a new term approval request |
| | |
| | | * |
| | | * @return int|false Request ID or false on failure |
| | | */ |
| | | public function createTermApprovalRequest( |
| | | int $user_id, |
| | | string $taxonomy, |
| | | string $name, |
| | | int $parent = 0, |
| | | int $required_approvals = 3 |
| | | ):int|false { |
| | | global $wpdb; |
| | | $table = $this->getRequestTable('term', $wpdb); |
| | | public function createTermApprovalRequest( |
| | | int $user_id, |
| | | string $taxonomy, |
| | | string $name, |
| | | int $parent = 0, |
| | | int $required_approvals = 3 |
| | | ): int|false { |
| | | $table = CustomTable::for($this->getTableName('term', 'requests')); |
| | | |
| | | try { |
| | | $wpdb->query('START TRANSACTION'); |
| | | // Step 1: Check if user already has a pending request for this term |
| | | $existing = $wpdb->get_row($wpdb->prepare( |
| | | "SELECT id, requested_by FROM $table |
| | | WHERE name = %s |
| | | AND taxonomy = %s |
| | | AND parent = %d |
| | | AND status = 'pending'", |
| | | $name, |
| | | $taxonomy, |
| | | $parent |
| | | )); |
| | | try { |
| | | return $table->transaction(function($table) use ($user_id, $taxonomy, $name, $parent, $required_approvals) { |
| | | // Check for existing request |
| | | $existing = $table->where([ |
| | | 'name' => $name, |
| | | 'taxonomy' => $taxonomy, |
| | | 'parent' => $parent, |
| | | 'status' => 'pending' |
| | | ])->first(); |
| | | |
| | | if ($existing) { |
| | | // Decode the requested_by JSON field |
| | | $requestedBy = json_decode($existing->requested_by, true) ?: []; |
| | | if ($existing) { |
| | | $requestedBy = json_decode($existing->requested_by, true) ?: []; |
| | | |
| | | // Check if this user has already requested this term |
| | | if (isset($requestedBy[$user_id])) { |
| | | $wpdb->query('COMMIT'); |
| | | return (int)$existing->id; |
| | | } |
| | | if (isset($requestedBy[$user_id])) { |
| | | return (int)$existing->id; |
| | | } |
| | | |
| | | // Add this user to the requesters |
| | | $requestedBy[$user_id] = get_userdata($user_id)->display_name; |
| | | $requestedBy[$user_id] = get_userdata($user_id)->display_name; |
| | | |
| | | // Update the request with the new requester |
| | | $updated = $wpdb->update( |
| | | $table, |
| | | ['requested_by' => json_encode($requestedBy)], |
| | | ['id' => $existing->id] |
| | | ); |
| | | $table->where(['id' => $existing->id])->updateResults([ |
| | | 'requested_by' => json_encode($requestedBy) |
| | | ]); |
| | | |
| | | if (!$updated) { |
| | | throw new Exception($wpdb->last_error); |
| | | } |
| | | return (int)$existing->id; |
| | | } |
| | | |
| | | $wpdb->query('COMMIT'); |
| | | return (int)$existing->id; |
| | | } |
| | | // Create new request |
| | | return $this->createApprovalRequest('term', [ |
| | | 'taxonomy' => $taxonomy, |
| | | 'name' => $name, |
| | | 'parent' => $parent ?: null, |
| | | 'status' => 'pending', |
| | | 'required_approvals' => $required_approvals, |
| | | 'current_approvals' => 0, |
| | | 'current_rejections' => 0, |
| | | 'requested_by' => json_encode([$user_id => get_userdata($user_id)->display_name]), |
| | | 'expires_at' => date('Y-m-d H:i:s', strtotime('+30 days')), |
| | | ]); |
| | | }); |
| | | } catch (Exception $e) { |
| | | $this->logError('createTermApprovalRequest', [ |
| | | 'error' => $e->getMessage(), |
| | | 'user_id' => $user_id, |
| | | 'taxonomy' => $taxonomy, |
| | | 'name' => $name |
| | | ]); |
| | | |
| | | $request = [ |
| | | 'taxonomy' => $taxonomy, |
| | | 'name' => $name, |
| | | 'parent' => $parent ?: null, |
| | | 'status' => 'pending', |
| | | 'required_approvals' => $required_approvals, |
| | | 'current_approvals' => 0, |
| | | 'current_rejections' => 0, |
| | | 'requested_by' => json_encode([$user_id => get_userdata($user_id)->display_name]), |
| | | 'expires_at' => date('Y-m-d H:i:s', strtotime('+30 days')), |
| | | 'created_at' => current_time('mysql'), |
| | | 'updated_at' => current_time('mysql') |
| | | ]; |
| | | $result = $this->createApprovalRequest('term', $request); |
| | | |
| | | if (!$result) { |
| | | throw new Exception($wpdb->last_error); |
| | | } |
| | | |
| | | $request_id = $wpdb->insert_id; |
| | | $wpdb->query('COMMIT'); |
| | | return $request_id; |
| | | } catch (Exception $e) { |
| | | $wpdb->query('ROLLBACK'); |
| | | JVB()->error() |
| | | ->log( |
| | | '[ApprovalRoutes]:createTermApprovalRequest', |
| | | "Error creating term approval request: " . $e->getMessage(), |
| | | [ |
| | | 'user_id' => $user_id, |
| | | 'taxonomy' => $taxonomy, |
| | | 'name' => $name |
| | | ] |
| | | ); |
| | | |
| | | return false; |
| | | } |
| | | } |
| | | return false; |
| | | } |
| | | } |
| | | /** |
| | | * Clean up expired approval requests and notify admin |
| | | * |
| | | * @return void |
| | | */ |
| | | public function cleanupExpiredApprovals(): void |
| | | { |
| | | global $wpdb; |
| | | $tables = array_map(function ($table) use ($wpdb){ |
| | | return $wpdb->prefix . BASE . 'approval_'.$table.'_requests'; |
| | | }, $this->allTypes); |
| | | public function cleanupExpiredApprovals(): void |
| | | { |
| | | $now = current_time('mysql'); |
| | | |
| | | foreach ($this->allTypes as $type) { |
| | | $tableName = $this->getTableName($type, 'requests'); |
| | | |
| | | foreach ($tables as $table) { |
| | | $wpdb->query($wpdb->prepare( |
| | | "UPDATE $table SET status = 'expired', updated_at = %s |
| | | WHERE status = 'pending' AND expires_at < %s", |
| | | current_time('mysql'), |
| | | current_time('mysql') |
| | | )); |
| | | } |
| | | CustomTable::for($tableName)->query( |
| | | "UPDATE {table} |
| | | SET status = 'expired' |
| | | WHERE status = 'pending' |
| | | AND expires_at < %s", |
| | | [$now] |
| | | ); |
| | | } |
| | | |
| | | // Clear caches |
| | | $this->cache->flush(); |
| | | } |
| | | } |
| | | |
| | | public function getApprovals(WP_REST_Request $request) |
| | | { |
| | | $user_id = get_current_user_id(); |
| | | $params = $request->get_params(); |
| | | $type = $params['type'] ?? 'all'; |
| | | $status = $params['status'] ?? 'pending'; |
| | | protected function getTableName(string $type, string $suffix): string |
| | | { |
| | | return match ($type) { |
| | | 'term' => "approval_term_{$suffix}", |
| | | default => "approval_{$type}_{$suffix}", |
| | | }; |
| | | } |
| | | |
| | | // Get appropriate approvals based on type |
| | | if ($type === 'user' || $type === 'all') { |
| | | $user_approvals = $this->getUserApprovals($status); |
| | | } else { |
| | | $user_approvals = []; |
| | | } |
| | | public function getApprovals(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $user_id = absint($request->get_param('user')); |
| | | $type = sanitize_text_field($request->get_param('type') ?? 'all'); |
| | | $status = sanitize_text_field($request->get_param('status') ?? 'pending'); |
| | | |
| | | if ($type === 'term' || $type === 'all') { |
| | | $term_approvals = $this->getTermApprovals($status); |
| | | } else { |
| | | $term_approvals = []; |
| | | } |
| | | if (!$this->checkUser($user_id)) { |
| | | return $this->unauthorized(); |
| | | } |
| | | |
| | | return new WP_REST_Response([ |
| | | 'user_approvals' => $user_approvals, |
| | | 'term_approvals' => $term_approvals |
| | | ]); |
| | | } |
| | | $cacheKey = compact('user_id', 'type', 'status'); |
| | | |
| | | private function getUserApprovals(string $status = 'pending'): array |
| | | { |
| | | global $wpdb; |
| | | $table = $wpdb->prefix . $this->userRequests; |
| | | $result = $this->cache->remember($cacheKey, function() use ($type, $status) { |
| | | $data = []; |
| | | |
| | | // Build the status condition |
| | | $status_condition = ($status === 'all') ? |
| | | "status IN ('pending', 'approved', 'rejected', 'expired')" : |
| | | $wpdb->prepare("status = %s", $status); |
| | | if ($type === 'user' || $type === 'all') { |
| | | $data['user_approvals'] = $this->getUserApprovals($status); |
| | | } |
| | | |
| | | return $wpdb->get_results( |
| | | "SELECT * FROM $table |
| | | WHERE $status_condition |
| | | ORDER BY created_at DESC" |
| | | ); |
| | | } |
| | | if ($type === 'term' || $type === 'all') { |
| | | $data['term_approvals'] = $this->getTermApprovals($status); |
| | | } |
| | | |
| | | private function getTermApprovals(string $status = 'pending'): array |
| | | { |
| | | global $wpdb; |
| | | $table = $wpdb->prefix . $this->termRequests; |
| | | return $data; |
| | | }); |
| | | |
| | | // Build the status condition |
| | | $status_condition = ($status === 'all') ? |
| | | "status IN ('pending', 'approved', 'rejected', 'expired')" : |
| | | $wpdb->prepare("status = %s", $status); |
| | | return $this->success($result); |
| | | } |
| | | |
| | | return $wpdb->get_results( |
| | | "SELECT * FROM $table |
| | | WHERE $status_condition |
| | | ORDER BY created_at DESC" |
| | | ); |
| | | } |
| | | private function getUserApprovals(string $status = 'pending'): array |
| | | { |
| | | $table = CustomTable::for($this->getTableName('artist', 'requests')); |
| | | |
| | | $query = $table; |
| | | |
| | | if ($status !== 'all') { |
| | | $query = $query->where(['status' => $status]); |
| | | } |
| | | |
| | | return $query->orderBy('created_at', 'DESC')->getResults(ARRAY_A); |
| | | } |
| | | |
| | | private function getTermApprovals(string $status = 'pending'): array |
| | | { |
| | | $table = CustomTable::for($this->getTableName('term', 'requests')); |
| | | |
| | | if ($status === 'all') { |
| | | return $table->orderBy('created_at', 'DESC')->getResults(ARRAY_A); |
| | | } |
| | | |
| | | return $table |
| | | ->where(['status' => $status]) |
| | | ->orderBy('created_at', 'DESC') |
| | | ->getResults(ARRAY_A); |
| | | } |
| | | } |
| | |
| | | |
| | | namespace JVBase\rest\routes; |
| | | |
| | | use JVBase\JVB; |
| | | use JVBase\managers\queue\executors\ContentExecutor; |
| | | use JVBase\managers\queue\Storage; |
| | | use JVBase\managers\queue\TypeConfig; |
| | | use JVBase\rest\RestRouteManager; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\rest\PermissionHandler; |
| | | use JVBase\rest\Response; |
| | | use JVBase\rest\Rest; |
| | | use JVBase\managers\Cache; |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\rest\Route; |
| | | use JVBase\utility\Features; |
| | | use WP_Post; |
| | | use WP_Query; |
| | |
| | | exit; // Exit if accessed directly |
| | | } |
| | | |
| | | class ContentRoutes extends RestRouteManager |
| | | class ContentRoutes extends Rest |
| | | { |
| | | protected array $fields = []; |
| | | protected array $taxonomies = []; |
| | | protected MetaManager $meta; |
| | | protected string $post_type = ''; |
| | | protected string $user_id = ''; |
| | | |
| | | //For Timeline-specific posts |
| | | protected array $timelineSharedFields = []; |
| | | protected array $timelineUniqueFields = []; |
| | | protected static ?string $action = 'dash-'; |
| | | protected Meta $meta; |
| | | |
| | | public function __construct() |
| | | { |
| | | $this->cache_name = 'user_content_' . get_current_user_id(); |
| | | $this->cacheName = 'user_content_' . get_current_user_id(); |
| | | parent::__construct(); |
| | | if (JVB_TESTING) { |
| | | $this->cache->flush(); |
| | | } |
| | | $this->cache->connect('post', true); |
| | | |
| | | $this->action = 'dash-'; |
| | | $this->operation_type = 'content_update'; |
| | | add_action('init', [$this, 'registerContentExecutors'], 5); |
| | | } |
| | | |
| | |
| | | */ |
| | | public function registerRoutes(): void |
| | | { |
| | | // Base content endpoint |
| | | register_rest_route($this->namespace, "/content", [ |
| | | [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'handleContentRequest'], |
| | | 'permission_callback' => [$this, 'checkPermission'], |
| | | ], |
| | | [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleContentUpdate'], |
| | | 'permission_callback' => [$this, 'checkPermission'] |
| | | ] |
| | | ]); |
| | | |
| | | //TODO: consolidate create/batch in with create? I don't think we are ever creating a single item |
| | | register_rest_route($this->namespace, "/create", [ |
| | | [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleContentCreate'], |
| | | 'permission_callback' => [$this, 'checkPermission'] |
| | | ] |
| | | ]); |
| | | register_rest_route($this->namespace, "/create/batch", [ |
| | | [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleBatchCreation'], |
| | | 'permission_callback' => [$this, 'checkPermission'] |
| | | ] |
| | | ]); |
| | | Route::for('content') |
| | | ->get([$this, 'getContent']) |
| | | ->auth(PermissionHandler::combine(['user', 'nonce', ['actionNonce'=>'dash-']])) |
| | | ->rateLimit(20) |
| | | ->post([$this, 'postContent']) |
| | | ->auth(PermissionHandler::combine(['user', 'nonce', ['actionNonce'=>'dash-']])) |
| | | ->rateLimit(30); |
| | | } |
| | | |
| | | protected function initTimelineFields(string $content): void |
| | |
| | | * |
| | | * @return WP_REST_Response |
| | | */ |
| | | public function handleContentUpdate(WP_REST_Request $request): WP_REST_Response |
| | | public function postContent(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $data = $request->get_params(); |
| | | $user_id = $data['user']; |
| | | |
| | | if (!$this->userCheck($user_id)) { |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => 'You for real?' |
| | | ]); |
| | | } |
| | | |
| | | if (!array_key_exists('posts', $data) || !is_array($data['posts'])) { |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => 'No posts found' |
| | | ]); |
| | | return Response::success(['message'=>'No posts found in request']); |
| | | } |
| | | |
| | | $count = count($data['posts']); |
| | |
| | | 'operation_id' => $operationId |
| | | ] |
| | | ); |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => 'Queued for processing', |
| | | 'operation' => $operationId |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Handle content creation |
| | | * @param WP_REST_Request $request |
| | | * |
| | | * @return WP_REST_Response |
| | | */ |
| | | public function handleContentCreate(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $data = $request->get_json_params(); |
| | | $user_id = $data['user']; |
| | | |
| | | if (!isset($data['posts']) || !is_array($data['posts'])) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Invalid request format' |
| | | ]); |
| | | } |
| | | |
| | | $count = count($data['posts']); |
| | | $operationId = $data['id']; |
| | | unset($data['user']); |
| | | unset($data['id']); |
| | | JVB()->queue()->queueOperation( |
| | | 'batch_creation', |
| | | $user_id, |
| | | $data, |
| | | [ |
| | | 'count' => $count, |
| | | 'operation_id' => $operationId, |
| | | ] |
| | | ); |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => 'Queued for processing', |
| | | 'operation' => $operationId |
| | | ]); |
| | | return Response::queued($operationId); |
| | | } |
| | | |
| | | |
| | |
| | | * |
| | | * @return WP_REST_Response |
| | | */ |
| | | public function handleContentRequest(WP_REST_Request $request): WP_REST_Response |
| | | public function getContent(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $params = $request->get_params(); |
| | | $user_id = $params['user']; |
| | | |
| | | if (!$this->userCheck($user_id)) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'User does not match up. Are you a bot?', |
| | | ]); |
| | | } |
| | | |
| | | $post_status = $params['status']; |
| | | if ($post_status === 'all') { |
| | | $post_status = ['publish', 'draft']; |
| | |
| | | return $cache_check; |
| | | } |
| | | |
| | | |
| | | $cache = $this->cache->get($key); |
| | | if ($cache) { |
| | | $response = new WP_REST_Response($cache); |
| | | $response = Response::success($cache); |
| | | return $this->addCacheHeaders($response); |
| | | } |
| | | |
| | |
| | | $data = [ |
| | | 'items' => $posts, |
| | | 'total' => $query->found_posts, |
| | | 'total_pages' => $query->max_num_pages |
| | | 'total_pages' => $query->max_num_pages, |
| | | 'has_more' => $args['paged']??1 < $query->max_num_pages, |
| | | ]; |
| | | |
| | | |
| | | $this->cache->set($key, $data); |
| | | |
| | | $response = new WP_REST_Response($data); |
| | | $response = Response::success($data); |
| | | return $this->addCacheHeaders($response); |
| | | } |
| | | |
| | |
| | | return $out; |
| | | } |
| | | |
| | | /** |
| | | * Processes operation from queue |
| | | * @param object $operation |
| | | * @param array $data |
| | | * |
| | | * @return array |
| | | */ |
| | | protected function processBatches(object $operation, array $data): array |
| | | { |
| | | $this->user_id = $operation->user_id; |
| | | $posts = $data['posts']; |
| | | |
| | | if (empty($posts)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'No posts to update' |
| | | ]; |
| | | } |
| | | |
| | | $results = []; |
| | | |
| | | foreach ($posts as $ID => $post_data) { |
| | | if (Features::forContent($post_data['content'])->has('is_timeline') && array_key_exists('timeline', $post_data)) { |
| | | // Handle timeline posts - ensure we have a valid integer ID |
| | | $parent_id = (int)$ID; |
| | | |
| | | // Skip if ID is invalid (0, 'null', etc would become 0) |
| | | if ($parent_id === 0) { |
| | | error_log('Invalid timeline parent ID: ' . $ID); |
| | | $results[$ID] = [ |
| | | 'success' => false, |
| | | 'message' => 'Invalid parent post ID for timeline' |
| | | ]; |
| | | continue; |
| | | } |
| | | |
| | | $results[$ID] = $this->processTimelinePost($parent_id, $post_data); |
| | | continue; |
| | | } |
| | | if (str_starts_with($ID, 'new')) { |
| | | |
| | | error_log('New post detected. Creating... with: ' . print_r([ |
| | | 'post_author' => $this->user_id, |
| | | 'post_type' => jvbCheckBase($post_data['content']), |
| | | 'post_title' => $post_data['post_title'] ?? '', |
| | | 'post_status' => $post_data['status'] ?? 'draft', |
| | | ], true)); |
| | | error_log('Recieved Data: ' . print_r($post_data, true)); |
| | | $ID = wp_insert_post([ |
| | | 'post_author' => $this->user_id, |
| | | 'post_type' => jvbCheckBase($post_data['content']), |
| | | 'post_title' => $post_data['post_title'] ?? '', |
| | | 'post_status' => $post_data['status'] ?? 'draft', |
| | | ]); |
| | | if (!$ID || is_wp_error($ID)) { |
| | | $results[$ID] = [ |
| | | 'success' => false, |
| | | 'message' => 'Couldn\'t Create Post' |
| | | ]; |
| | | continue; |
| | | } |
| | | $fields = jvbGetFields($post_data['content']); |
| | | $allowedFields = array_filter($post_data, function ($key) use ($fields) { |
| | | return array_key_exists($key, $fields); |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | |
| | | $meta = new MetaManager($ID, 'post'); |
| | | $success = $meta->setAll($allowedFields); |
| | | $results[$ID] = [ |
| | | 'success' => $success |
| | | ]; |
| | | } else { |
| | | if (!$this->verifyOwnership($ID)) { |
| | | $results[$ID] = [ |
| | | 'success' => false, |
| | | 'message' => 'No permission to modify this post' |
| | | ]; |
| | | continue; |
| | | } |
| | | error_log('Saving post data: ' . print_r($post_data, true)); |
| | | |
| | | if (array_key_exists('post_status', $post_data)) { |
| | | switch ($post_data['post_status']) { |
| | | case 'publish': |
| | | unset($post_data['post_status']); |
| | | if (user_can($this->user_id, 'manage_options') || user_can($this->user_id, 'skip_moderation')) { |
| | | $result = wp_update_post(['ID' => $ID, 'post_status' => 'publish']); |
| | | } |
| | | break; |
| | | case 'draft': |
| | | $result = wp_update_post([ |
| | | 'ID' => $ID, |
| | | 'post_status' => 'draft' |
| | | ]); |
| | | break; |
| | | case 'trash': |
| | | $result = wp_trash_post($ID); |
| | | break; |
| | | case 'delete': |
| | | $result = wp_delete_post($ID, true); |
| | | return ['success' => (bool)$result]; |
| | | } |
| | | } |
| | | error_log('Updating data: ' . print_r($post_data, true)); |
| | | $fields = jvbGetFields($post_data['content']); |
| | | $allowedFields = array_filter($post_data, function ($key) use ($fields) { |
| | | return array_key_exists($key, $fields); |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | |
| | | error_log('Allowed Fields: ' . print_r($allowedFields, true)); |
| | | $meta = new MetaManager($ID, 'post'); |
| | | $success = $meta->setAll($allowedFields); |
| | | $results[$ID] = [ |
| | | 'success' => $success |
| | | ]; |
| | | |
| | | } |
| | | } |
| | | |
| | | if (jvbSiteHasNotifications()) { |
| | | $this->notifications = JVB()->notification(); |
| | | $this->notifications->addNotification( |
| | | $this->user_id, |
| | | 'content_update_complete', |
| | | null, |
| | | 'Content updates completed!' |
| | | ); |
| | | } |
| | | |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => $results |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Extracts the postdata for timeline post child posts from the pseudo-repeater element |
| | | * @param int $parent_id |
| | | * @param array $post_data |
| | | * @return array|true[] |
| | | */ |
| | | protected function processTimelinePost(int $parent_id, array $post_data): array |
| | | { |
| | | if (!$this->verifyOwnership($parent_id)) { |
| | | return ['success' => false, 'message' => 'No permission']; |
| | | } |
| | | |
| | | error_log('[Processing Timeline Post...'); |
| | | |
| | | $ignore = ['content', 'user']; |
| | | $this->fields = jvbGetFields($post_data['content']); |
| | | $this->initTimelineFields($post_data['content']); |
| | | |
| | | // Get parent post details |
| | | $parent_post = get_post($parent_id); |
| | | $parent_title = $parent_post->post_title; |
| | | $parent_is_published = ($parent_post->post_status === 'publish'); |
| | | |
| | | // Extract shared data from top level (excluding post_thumbnail which is unique per post) |
| | | $sharedData = array_filter($post_data, function ($key) use ($ignore) { |
| | | return in_array($key, $this->timelineSharedFields) |
| | | && !in_array($key, $ignore) |
| | | && $key !== 'post_thumbnail'; |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | |
| | | // If no shared post_title at top level, extract from first timeline entry |
| | | if (!isset($sharedData['post_title']) && isset($post_data['timeline'][0]['post_title'])) { |
| | | $sharedData['post_title'] = $post_data['timeline'][0]['post_title']; |
| | | } |
| | | $clearParent = false; |
| | | if (array_key_exists('timeline', $post_data) && is_array($post_data['timeline'])) { |
| | | // Remove post_title and post_thumbnail from shared taxonomies |
| | | $sharedTaxonomies = array_filter($sharedData, function ($key) { |
| | | return $key !== 'post_title' && $key !== 'post_thumbnail'; |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | |
| | | // Ensure the parent post exists and is still first in the array |
| | | $index = array_search((string)$parent_id, array_column($post_data['timeline'], 'id')); |
| | | |
| | | if ($index === false) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Missing parent id. This should not have happened' |
| | | ]; |
| | | } |
| | | |
| | | if ($index !== 0) { |
| | | $new_parent_id = $post_data['timeline'][0]['id']; |
| | | |
| | | if (is_numeric($new_parent_id) && (int)$new_parent_id > 0) { |
| | | $new_parent_id = (int)$new_parent_id; |
| | | wp_update_post([ |
| | | 'ID' => $new_parent_id, |
| | | 'post_parent' => 0 |
| | | ]); |
| | | |
| | | wp_update_post([ |
| | | 'ID' => $parent_id, |
| | | 'post_parent' => $new_parent_id |
| | | ]); |
| | | |
| | | $existing_children = get_children([ |
| | | 'post_parent' => $parent_id, |
| | | 'fields' => 'ids' |
| | | ]); |
| | | |
| | | foreach ($existing_children as $child_id) { |
| | | if ($child_id !== $new_parent_id) { |
| | | wp_update_post([ |
| | | 'ID' => $child_id, |
| | | 'post_parent' => $new_parent_id |
| | | ]); |
| | | } |
| | | } |
| | | |
| | | // Update parent references |
| | | $parent_id = $new_parent_id; |
| | | $parent_post = get_post($parent_id); |
| | | $parent_title = $parent_post->post_title; |
| | | $parent_is_published = ($parent_post->post_status === 'publish'); |
| | | } else { |
| | | $item = $post_data['timeline'][$index]; |
| | | unset($post_data['timeline'][$index]); |
| | | array_unshift($post_data['timeline'], $item); |
| | | } |
| | | } |
| | | |
| | | $errors = []; |
| | | $success = []; |
| | | $existing_children = get_children([ |
| | | 'post_parent' => $parent_id, |
| | | 'orderby' => 'menu_order', |
| | | 'post_status' => ['publish', 'draft'], |
| | | 'fields' => 'ids' |
| | | ]); |
| | | |
| | | $prevDate = null; |
| | | $latest_date = null; |
| | | $earliest_date = null; |
| | | foreach ($post_data['timeline'] as $order => $timeline) { |
| | | // Get unique fields for this specific timeline entry |
| | | $allowedFields = array_filter($timeline, function ($key) use ($ignore) { |
| | | return in_array($key, $this->timelineUniqueFields) && !in_array($key, $ignore); |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | |
| | | // Determine the post title |
| | | $is_parent = ((int)$timeline['id'] === $parent_id); |
| | | $provided_title = $timeline['post_title'] ?? ''; |
| | | $auto_generated_pattern = '/^.+Treatment #?\d+$/'; // Matches "Title - Treatment #1" or "Title - Treatment 1" |
| | | |
| | | if ($is_parent) { |
| | | // Parent keeps its own title or uses shared title |
| | | $allowedFields['post_title'] = $provided_title ?: ($sharedData['post_title'] ?? $parent_title); |
| | | } else { |
| | | // For child posts, auto-generate if: |
| | | // 1. No title provided, OR |
| | | // 2. Title matches auto-generated pattern (meaning it wasn't customized) |
| | | if (empty($provided_title) || preg_match($auto_generated_pattern, $provided_title)) { |
| | | $allowedFields['post_title'] = 'Treatment ' . $order; |
| | | } else { |
| | | // Keep custom title |
| | | $allowedFields['post_title'] = $provided_title; |
| | | } |
| | | } |
| | | |
| | | // Merge with shared taxonomies AFTER setting unique fields |
| | | $allowedFields = array_merge($sharedTaxonomies, $allowedFields); |
| | | |
| | | // Handle post creation if needed |
| | | if (!array_key_exists('id', $timeline) || !is_numeric($timeline['id'])) { |
| | | $newChild = wp_insert_post([ |
| | | 'post_author' => $this->user_id, |
| | | 'post_type' => jvbCheckBase($post_data['content']), |
| | | 'post_title' => $allowedFields['post_title'], |
| | | 'post_parent' => $parent_id, |
| | | 'menu_order' => $order, |
| | | 'post_status' => $parent_is_published ? 'publish' : 'draft' |
| | | ]); |
| | | if (!$newChild || is_wp_error($newChild)) { |
| | | $errors[] = [ |
| | | 'message' => 'Could not create child post', |
| | | 'data' => $timeline |
| | | ]; |
| | | continue; |
| | | } |
| | | $timeline['id'] = $newChild; |
| | | } |
| | | |
| | | if (in_array((int)$timeline['id'], $existing_children)) { |
| | | unset($existing_children[array_search((int)$timeline['id'], $existing_children)]); |
| | | } |
| | | |
| | | // Update post status and menu order |
| | | $post_updates = ['ID' => $timeline['id']]; |
| | | |
| | | if (!$is_parent) { |
| | | $post_updates['menu_order'] = $order; |
| | | |
| | | // Auto-publish child if parent is published |
| | | if ($parent_is_published) { |
| | | $current_post = get_post($timeline['id']); |
| | | if ($current_post && $current_post->post_status !== 'publish') { |
| | | $post_updates['post_status'] = 'publish'; |
| | | } |
| | | } |
| | | } |
| | | |
| | | if (count($post_updates) > 1) { |
| | | $result = wp_update_post($post_updates); |
| | | error_log('Updated post ' . $timeline['id'] . ' with: ' . print_r($post_updates, true) . ' Result: ' . $result); |
| | | $clearParent = true; |
| | | } |
| | | |
| | | // Update metadata |
| | | $meta = new MetaManager($timeline['id'], 'post'); |
| | | $oldValues = $meta->getAll(array_keys($allowedFields)); |
| | | |
| | | // // Set number taxonomy to menu_order (always update for reordering) |
| | | // if (!$is_parent) { |
| | | // $number_value = $order; |
| | | // $term = get_term_by('name', (string)$number_value, BASE . 'number'); |
| | | // if (!$term) { |
| | | // $result = wp_insert_term((string)$number_value, BASE . 'number'); |
| | | // if ($result && !is_wp_error($result)) { |
| | | // $term = $result['term_id']; |
| | | // } |
| | | // } else { |
| | | // $term = $term->term_id; |
| | | // } |
| | | // $allowedFields['number'] = $term; |
| | | // } |
| | | |
| | | // Auto-timeline logic |
| | | if ($prevDate) { |
| | | $newDate = array_key_exists('date', $oldValues) ? $oldValues['date'] : ((array_key_exists('date', $allowedFields)) ? $allowedFields['date'] : null); |
| | | if ($newDate) { |
| | | $date1 = new \DateTime($prevDate); |
| | | $date2 = new \DateTime($newDate); |
| | | $weeks = floor($date1->diff($date2)->days / 7); |
| | | if ($weeks > 0) { |
| | | $termToCheck = $weeks . ' Weeks'; |
| | | $term = get_term_by('name', $termToCheck, BASE . 'timeline'); |
| | | if (!$term) { |
| | | $result = wp_insert_term($termToCheck, BASE . 'timeline'); |
| | | if ($result && !is_wp_error($result)) { |
| | | $term = $result['term_id']; |
| | | } |
| | | } else { |
| | | $term = $term->term_id; |
| | | } |
| | | $allowedFields['timeline'] = $term; |
| | | } |
| | | } |
| | | } |
| | | $prevDate = array_key_exists('date', $oldValues) ? $oldValues['date'] : ((array_key_exists('date', $allowedFields)) ? $allowedFields['date'] : $prevDate); |
| | | |
| | | $updateValues = array_filter($allowedFields, function ($value, $key) use ($oldValues) { |
| | | return (!array_key_exists($key, $oldValues) || $value !== $oldValues[$key]); |
| | | }, ARRAY_FILTER_USE_BOTH); |
| | | |
| | | |
| | | $meta->setAll($updateValues); |
| | | $timeline['id'] = (int)$timeline['id']; |
| | | |
| | | $success[] = $timeline['id']; |
| | | } |
| | | } |
| | | |
| | | // Delete any remaining children that no longer exist |
| | | if (!empty($existing_children)) { |
| | | foreach ($existing_children as $ID) { |
| | | wp_delete_post($ID); |
| | | } |
| | | } |
| | | |
| | | if ($clearParent) { |
| | | $this->cache->flush(); |
| | | Cache::onPostChange($parent_id, $parent_post); |
| | | } |
| | | |
| | | |
| | | return ['success' => true, 'data' => [ |
| | | 'success' => $success, |
| | | 'errors' => $errors |
| | | ]]; |
| | | } |
| | | |
| | | /** |
| | | * Handle batch content creation from uploads |
| | | * @param WP_REST_Request $request |
| | | * |
| | | * @return WP_REST_Response |
| | | */ |
| | | public function handleBatchCreation(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | //Operation has two parts |
| | | //First, queue image processing |
| | | //Then queue post creation from the stored IDs, depending on mode |
| | | //if direct, each image becomes a new post |
| | | //if selection, each group becomes its own post, |
| | | // and ungrouped items each become their own post |
| | | if (!isset($_FILES['files'])) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'No files uploaded...', |
| | | ]); |
| | | } |
| | | |
| | | $data = $request->get_params(); |
| | | |
| | | |
| | | $user_id = $data['user']; |
| | | if (!$this->userCheck($user_id)) { |
| | | return new WP_REST_Response([ |
| | | 'success' => 'false', |
| | | 'message' => 'Invalid user match... are you a bot?' |
| | | ]); |
| | | } |
| | | $operation_id = $data['id']; |
| | | $response = new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => 'Successfully sent to server. Added to queue.', |
| | | 'operation_id' => $operation_id, |
| | | 'status' => 'pending' |
| | | ]); |
| | | $this->queue = JVB()->queue(); |
| | | JVB()->routes('uploads')->handleUploadRequest($request, false); |
| | | $this->queue->queueOperation( |
| | | 'batch_creation', |
| | | $user_id, |
| | | [ |
| | | 'content' => $request->get_param('content'), |
| | | 'mode' => $request->get_param('mode') ?: 'direct', |
| | | 'files_data' => $request->get_param('files_data') |
| | | ], |
| | | [ |
| | | 'operation_id' => $operation_id, |
| | | 'priority' => 'high', |
| | | 'notification' => true, |
| | | 'depends_on' => $operation_id . '_upload' |
| | | ] |
| | | ); |
| | | |
| | | return $response; |
| | | } |
| | | |
| | | /** |
| | | * Generates a post title, based on content type |
| | |
| | | $this->initTimelineFields($post->post_type); |
| | | return $this->formatTimeline($post); |
| | | } |
| | | $this->meta = new MetaManager($post->ID, 'post'); |
| | | $this->meta = Meta::forPost($post->ID); |
| | | $data = [ |
| | | 'id' => $post->ID, |
| | | 'title' => $post->post_title, |
| | |
| | | { |
| | | $item = $this->prepareItem($post, true, false); |
| | | //Step 1: Get the fields that apply to all posts |
| | | $mainMeta = new MetaManager($post->ID, 'post'); |
| | | $mainMeta = Meta::forPost($post->ID); |
| | | $item['fields'] = $mainMeta->getAll($this->timelineSharedFields); |
| | | |
| | | //Step 2: Get the fields for each individual posts |
| | |
| | | $subFields = []; |
| | | $images = []; |
| | | foreach ($children as $child) { |
| | | $meta = new MetaManager($child, 'post'); |
| | | $meta = Meta::forPost($child); |
| | | $f = $meta->getAll($this->timelineUniqueFields); |
| | | $f = ['id' => $child] + $f; |
| | | $subFields[] = $f; |
| | |
| | | } |
| | | $item['fields']['timeline'] = $subFields; |
| | | $item['images'] = $item['images'] + $images; |
| | | $item['number'] = $mainMeta->getValue('number'); |
| | | $item['number'] = $mainMeta->get('number'); |
| | | |
| | | return $item; |
| | | } |
| | | |
| | | /** |
| | | * Builds the taxonomy query |
| | | * @param array $taxonomies |
| | | * |
| | | * @return array|string[] |
| | | */ |
| | | protected function buildTaxQuery(array $taxonomies): array |
| | | { |
| | | $tax_query = []; |
| | | error_log('Taxonomies in query: ' . print_r($taxonomies, true)); |
| | | |
| | | foreach ($taxonomies as $taxonomy => $terms) { |
| | | if (!empty($terms)) { |
| | | $tax_query[] = [ |
| | | 'taxonomy' => jvbCheckBase($taxonomy), |
| | | 'field' => 'term_id', |
| | | 'terms' => array_map('absint', (array)$terms) |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | |
| | | return count($tax_query) > 1 |
| | | ? array_merge(['relation' => 'AND'], $tax_query) |
| | | : $tax_query; |
| | | } |
| | | |
| | | /** |
| | | * Builds the date query |
| | | * @param array $date_params |
| | | * |
| | | * @return array |
| | | */ |
| | | protected function buildDateQuery(array $date_params): array |
| | | { |
| | | $query = []; |
| | | |
| | | if (!empty($date_params['after'])) { |
| | | $query['after'] = sanitize_text_field($date_params['after']); |
| | | } |
| | | |
| | | if (!empty($date_params['before'])) { |
| | | $query['before'] = sanitize_text_field($date_params['before']); |
| | | } |
| | | |
| | | if (isset($date_params['inclusive'])) { |
| | | $query['inclusive'] = (bool)$date_params['inclusive']; |
| | | } |
| | | |
| | | return empty($query) ? [] : [$query]; |
| | | } |
| | | |
| | | /** |
| | | * @param int $post_id |
| | | * |
| | | * @return bool |
| | | */ |
| | | protected function verifyOwnership(int $post_id): bool |
| | | { |
| | | $post = get_post($post_id); |
| | | return $post && $post->post_author == $this->user_id; |
| | | } |
| | | |
| | | /** |
| | | * Processes operation from Operation Queue |
| | | * @deprecated We process Queue through ContentExecutor.php, setup in registerContentExecutors()) |
| | | * @param WP_Error|array $result |
| | | * @param object $operation |
| | | * @param array $data |
| | | * |
| | | * @return array|WP_Error |
| | | */ |
| | | public function processOperation(WP_Error|array $result, object $operation, array $data): array|WP_Error |
| | | { |
| | | if ($operation->type === 'batch_creation') { |
| | | $JVB = JVB(); |
| | | $queue = $JVB->queue(); |
| | | |
| | | $images = $queue->getOperationValue($operation->id . '_upload', 'result') ?? false; |
| | | |
| | | $this->user_id = $operation->user_id; |
| | | $this->post_type = BASE . $data['content']; |
| | | try { |
| | | $results = []; |
| | | if ($images) { |
| | | if ($data['mode'] == 'selection') { |
| | | $total = count($images); |
| | | foreach ($images as $group => $files) { |
| | | $settings = json_decode($data['files_data'][$group]); |
| | | |
| | | switch ($settings->type) { |
| | | case 'group': |
| | | $featuredIndex = $settings->metadata->featuredFile ?? 0; |
| | | $title = $settings->metadata->title ?? $this->generatePostTitle($data['content']); |
| | | $new = wp_insert_post([ |
| | | 'post_type' => BASE . $data['content'], |
| | | 'post_title' => $title, |
| | | 'post_status' => 'draft', |
| | | 'post_author' => $operation->user_id |
| | | ]); |
| | | if ($new && !is_wp_error($new)) { |
| | | set_post_thumbnail($new, $files[$featuredIndex]['attachment_id']); |
| | | unset($files[$featuredIndex]); |
| | | if (!empty($files)) { |
| | | $meta = new MetaManager($new, 'post'); |
| | | $IDs = array_column($files, 'attachment_id'); |
| | | $meta->updateValue('gallery', implode(',', $IDs)); |
| | | } |
| | | $results[] = $new; |
| | | // $queue->updateOperationProgress($operation->id, $group + 1, $total); |
| | | } |
| | | break; |
| | | default: |
| | | foreach ($files as $img) { |
| | | $new = wp_insert_post([ |
| | | 'post_type' => BASE . $data['content'], |
| | | 'post_title' => $this->generatePostTitle($data['content']), |
| | | 'post_status' => 'draft', |
| | | 'post_author' => $operation->user_id |
| | | ]); |
| | | |
| | | if ($new && !is_wp_error($new)) { |
| | | set_post_thumbnail($new, $img['attachment_id']); |
| | | $results[] = $new; |
| | | // $queue->updateOperationProgress($operation->id, $group + 1, $total); |
| | | } |
| | | } |
| | | break; |
| | | } |
| | | } |
| | | } else { |
| | | $total = count($images); |
| | | foreach ($images as $key => $img) { |
| | | $new = wp_insert_post([ |
| | | 'post_type' => BASE . $data['content'], |
| | | 'post_title' => $this->generatePostTitle($data['content']), |
| | | 'post_status' => 'draft', |
| | | 'post_author' => $operation->user_id |
| | | ]); |
| | | if ($new && !is_wp_error($new)) { |
| | | set_post_thumbnail($new, $img['attachment_id']); |
| | | } |
| | | $results[] = $new; |
| | | // $queue->updateOperationProgress($operation->id, $key + 1, $total); |
| | | } |
| | | } |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => $results |
| | | ]; |
| | | } catch (Exception $e) { |
| | | $JVB->error()->log( |
| | | '[ContentRoutes]:processOperation', |
| | | $e->getMessage() |
| | | ); |
| | | } |
| | | |
| | | return $results; |
| | | } elseif ($operation->type == 'content_update') { |
| | | $result = $this->processBatches($operation, $data); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | // Add to ContentRoutes.php |
| | | |
| | | /** |
| | | * One-time migration: Set latest_date meta for all timeline posts |
| | | * Call this once via WP-CLI or a temporary admin page |
| | | * |
| | | * Usage: add_action('admin_init', function() { |
| | | * if (current_user_can('manage_options')) { |
| | | * JVB()->routes('content')->migrateTimelineLatestDates(); |
| | | * } |
| | | * }); |
| | | */ |
| | | public function migrateTimelineLatestDates(): array |
| | | { |
| | | global $wpdb; |
| | | |
| | | $results = [ |
| | | 'processed' => 0, |
| | | 'updated' => 0, |
| | | 'skipped' => 0, |
| | | 'errors' => [] |
| | | ]; |
| | | |
| | | // Get all timeline post types |
| | | $timeline_types = []; |
| | | foreach (JVB_CONTENT as $type => $config) { |
| | | if (Features::forContent($type)->has('is_timeline')) { |
| | | $timeline_types[] = BASE . $type; |
| | | } |
| | | } |
| | | |
| | | if (empty($timeline_types)) { |
| | | return $results; |
| | | } |
| | | |
| | | // Get all parent timeline posts |
| | | $args = [ |
| | | 'post_type' => $timeline_types, |
| | | 'post_status' => ['publish', 'draft'], |
| | | 'post_parent' => 0, |
| | | 'posts_per_page' => -1, |
| | | 'fields' => 'ids' |
| | | ]; |
| | | |
| | | $parent_ids = get_posts($args); |
| | | |
| | | foreach ($parent_ids as $parent_id) { |
| | | $results['processed']++; |
| | | |
| | | try { |
| | | // Get all children including the parent |
| | | $children = get_children([ |
| | | 'post_parent' => $parent_id, |
| | | 'post_status' => ['publish', 'draft'], |
| | | 'orderby' => 'menu_order', |
| | | 'order' => 'ASC', |
| | | 'fields' => 'ids' |
| | | ]); |
| | | |
| | | // Add parent to the list |
| | | array_unshift($children, $parent_id); |
| | | |
| | | // Find latest date among all posts |
| | | $latest_timestamp = 0; |
| | | |
| | | foreach ($children as $post_id) { |
| | | $date = get_post_meta($post_id, BASE . 'date', true); |
| | | |
| | | if ($date) { |
| | | $timestamp = strtotime($date); |
| | | if ($timestamp > $latest_timestamp) { |
| | | $latest_timestamp = $timestamp; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Update parent with latest date |
| | | if ($latest_timestamp > 0) { |
| | | update_post_meta($parent_id, BASE . 'latest_date', $latest_timestamp); |
| | | $results['updated']++; |
| | | error_log("Updated post {$parent_id} with latest_date: {$latest_timestamp}"); |
| | | } else { |
| | | // Fallback to parent post's post_date |
| | | $parent_post = get_post($parent_id); |
| | | $fallback_timestamp = strtotime($parent_post->post_date); |
| | | |
| | | if ($fallback_timestamp > 0) { |
| | | update_post_meta($parent_id, BASE . 'latest_date', $fallback_timestamp); |
| | | $results['updated']++; |
| | | error_log("Updated post {$parent_id} with fallback latest_date: {$fallback_timestamp} (from post_date)"); |
| | | } else { |
| | | $results['skipped']++; |
| | | error_log("No dates found for post {$parent_id}"); |
| | | } |
| | | } |
| | | |
| | | } catch (Exception $e) { |
| | | $results['errors'][] = [ |
| | | 'post_id' => $parent_id, |
| | | 'error' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | error_log('Timeline migration complete: ' . print_r($results, true)); |
| | | return $results; |
| | | } |
| | | } |
| | | |
| | | |
| | | //add_action('init', function() { |
| | | //// delete_option('jvb_timeline_migrated'); |
| | | // if (get_option('jvb_timeline_migrated')) { |
| | | // return; |
| | | // } |
| | | // JVB()->routes('content')->migrateTimelineLatestDates(); |
| | | // update_option('jvb_timeline_migrated', true); |
| | | //}); |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\rest\routes; |
| | | |
| | | use JVBase\rest\Rest; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | class ContentTermsRoutes extends Rest |
| | | { |
| | | |
| | | public function registerRoutes(): void |
| | | { |
| | | // TODO: Implement registerRoutes() method. |
| | | } |
| | | } |
| | |
| | | <?php |
| | | namespace JVBase\rest\routes; |
| | | |
| | | use JVBase\JVB; |
| | | use JVBase\rest\RestRouteManager; |
| | | use JVBase\rest\Response; |
| | | use JVBase\rest\Rest; |
| | | use JVBase\rest\Route; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; // Exit if accessed directly |
| | | } |
| | | class ErrorRoutes extends RestRouteManager |
| | | class ErrorRoutes extends Rest |
| | | { |
| | | /** |
| | | * Registers error routes |
| | |
| | | */ |
| | | public function registerRoutes():void |
| | | { |
| | | register_rest_route($this->namespace, '/errors/log', [ |
| | | [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleErrorLog'], |
| | | 'permission_callback' => '__return_true', // Allow anyone to log errors |
| | | ] |
| | | ]); |
| | | Route::for('errors/log') |
| | | ->post([$this, 'handleErrorLog']) |
| | | ->args([ |
| | | 'error_type' => 'string|required|enum:network,timeout,offline,auth,rate_limit,server,client,unknown', |
| | | 'message' => 'string|required', |
| | | 'context' => 'string', |
| | | ]) |
| | | ->auth('public') |
| | | ->rateLimit(10); |
| | | } |
| | | |
| | | /** |
| | |
| | | * |
| | | * @return WP_REST_Response |
| | | */ |
| | | public function handleErrorLog(WP_REST_Request$request):WP_REST_Response |
| | | { |
| | | $error_type = $request->get_param('error_type'); |
| | | $message = $request->get_param('message'); |
| | | $context = json_decode($request->get_param('context'), true); |
| | | public function handleErrorLog(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $error_type = sanitize_text_field($request->get_param('error_type')); |
| | | $message = sanitize_text_field($request->get_param('message')); |
| | | $context = $request->get_param('context'); |
| | | |
| | | // Determine severity based on error type |
| | | $severity = $this->getSeverityFromType($error_type); |
| | | // Parse context JSON if provided |
| | | $contextData = []; |
| | | if (!empty($context)) { |
| | | $decoded = json_decode($context, true); |
| | | $contextData = is_array($decoded) ? $decoded : []; |
| | | } |
| | | |
| | | JVB()->error()->log( |
| | | $context['component'] ?? 'client-js', |
| | | $message, |
| | | $context, |
| | | $severity |
| | | ); |
| | | // Determine severity based on error type |
| | | $severity = $this->getSeverityFromType($error_type); |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => 'Error logged' |
| | | ]); |
| | | } |
| | | JVB()->error()->log( |
| | | $contextData['component'] ?? 'client-js', |
| | | $message, |
| | | $contextData, |
| | | $severity |
| | | ); |
| | | |
| | | return Response::success(['message'=>'Error logged']); |
| | | } |
| | | |
| | | /** |
| | | * @param string $type |
| | |
| | | <?php |
| | | namespace JVBase\rest\routes; |
| | | |
| | | use JVBase\managers\Cache; |
| | | use JVBase\rest\RestRouteManager; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\rest\Rest; |
| | | use JVBase\integrations\Umami; |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\managers\TaxonomyRelationships; |
| | | use JVBase\rest\Route; |
| | | use JVBase\utility\Checker; |
| | | use JVBase\utility\Features; |
| | | use WP_Query; |
| | |
| | | exit; // Exit if accessed directly |
| | | } |
| | | |
| | | class FeedRoutes extends RestRouteManager |
| | | class FeedRoutes extends Rest |
| | | { |
| | | protected int $per_page = 36; |
| | | protected ?Umami $tracker = null; |
| | |
| | | |
| | | public function __construct() |
| | | { |
| | | $this->cache_name = 'feed'; |
| | | $this->cache_ttl = 86400; |
| | | $this->cacheName = 'feed'; |
| | | $this->cacheTtl = 86400; |
| | | parent::__construct(); |
| | | $this->cache |
| | | ->connect('post', true) |
| | |
| | | */ |
| | | public function registerRoutes(): void |
| | | { |
| | | register_rest_route($this->namespace, '/feed', [ |
| | | 'methods' => ['GET', 'POST'], |
| | | 'callback' => [$this, 'handleFeedRequest'], |
| | | 'permission_callback' => [$this, 'checkPermission'], |
| | | ]); |
| | | Route::for('feed') |
| | | ->get([$this, 'handleFeedRequest']) |
| | | ->args([ |
| | | 'content' => 'string', |
| | | 'page' => 'integer|default:1|min:1', |
| | | 'taxonomy' => 'string', |
| | | 'match' => 'string|enum:all,any|default:all', |
| | | 'orderby' => 'string', |
| | | 'order' => 'string|enum:ASC,DESC', |
| | | 'date-filter' => 'string', |
| | | 'dateFrom' => 'string', |
| | | 'dateTo' => 'string', |
| | | 'context' => 'string', |
| | | 'source' => 'string', |
| | | 'favourites' => 'boolean', |
| | | 'user' => 'integer', |
| | | 'highlight' => 'string', |
| | | ]) |
| | | ->auth('public') |
| | | ->rateLimit(30, 60) |
| | | ->post([$this, 'handleFeedRequest']) |
| | | ->args([ |
| | | 'content' => 'string', |
| | | 'page' => 'integer|default:1|min:1', |
| | | 'taxonomy' => 'string', |
| | | 'match' => 'string|enum:all,any|default:all', |
| | | 'orderby' => 'string', |
| | | 'order' => 'string|enum:ASC,DESC', |
| | | 'date-filter' => 'string', |
| | | 'dateFrom' => 'string', |
| | | 'dateTo' => 'string', |
| | | 'context' => 'string', |
| | | 'source' => 'string', |
| | | 'favourites' => 'boolean', |
| | | 'user' => 'integer', |
| | | 'highlight' => 'string', |
| | | ]) |
| | | ->auth('public') |
| | | ->rateLimit(30, 60); |
| | | |
| | | register_rest_route($this->namespace, 'feed/types', [ |
| | | 'permission_callback' => [$this, 'checkPermission'], |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getFeedTypes'] |
| | | ]); |
| | | // Feed types endpoint |
| | | Route::for('feed/types') |
| | | ->get([$this, 'getFeedTypes']) |
| | | ->auth('public') |
| | | ->rateLimit(60, 60); |
| | | } |
| | | |
| | | /** |
| | |
| | | switch ($metaType) { |
| | | case 'post': |
| | | $config = JVB_CONTENT[$type]; |
| | | |
| | | $meta = Meta::forPost($postID); |
| | | if (!$skip && array_key_exists('is_timeline', $config) && $config['is_timeline']) { |
| | | return $this->formatTimeline($postID, $post); |
| | | } |
| | | break; |
| | | case 'term': |
| | | |
| | | $meta = Meta::forTerm($postID); |
| | | $config = JVB_TAXONOMY[$type]; |
| | | break; |
| | | } |
| | |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | } |
| | | |
| | | $meta = new MetaManager($postID, $metaType); |
| | | $values = $meta->getAll(array_keys($fields)); |
| | | |
| | | $out = [ |
| | |
| | | } |
| | | $item = $this->formatItem($postID, 'post', true); |
| | | //Step 1: Get the fields that apply to all posts |
| | | $mainMeta = new MetaManager($post->ID, 'post'); |
| | | $mainMeta = Meta::forPost($post->ID); |
| | | $item['fields'] = $mainMeta->getAll($this->timelineSharedFields); |
| | | |
| | | //Step 2: Get the fields for each individual posts |
| | |
| | | $subFields = []; |
| | | $images = []; |
| | | foreach ($children as $child) { |
| | | $meta = new MetaManager($child, 'post'); |
| | | $meta = Meta::forPost($child); |
| | | $f = $meta->getAll($this->timelineUniqueFields); |
| | | $f = ['id' => $child] + $f; |
| | | $subFields[] = $f; |
| | |
| | | return $this->applyFavouritesFilter($args, $data); |
| | | } |
| | | |
| | | // protected function applyTaxonomyFilters(array $args, array $data): array |
| | | // { |
| | | // if (!array_key_exists('taxonomy', $data) || empty($data['taxonomy'])) { |
| | | // return $args; |
| | | // } |
| | | // |
| | | // $taxonomyFilters = $data['taxonomy']; |
| | | // |
| | | // // Validate taxonomies exist and sanitize |
| | | // $validFilters = []; |
| | | // foreach ($taxonomyFilters as $taxonomy => $terms) { |
| | | // if (!taxonomy_exists(jvbCheckBase($taxonomy))) { |
| | | // continue; |
| | | // } |
| | | // |
| | | // $validFilters[] = [ |
| | | // 'taxonomy' => jvbCheckBase($taxonomy), |
| | | // 'field' => 'term_id', |
| | | // 'terms' => array_map('absint', (array)$terms), |
| | | // 'operator' => 'IN' |
| | | // ]; |
| | | // } |
| | | // |
| | | // if (empty($validFilters)) { |
| | | // return $args; |
| | | // } |
| | | // |
| | | // // Determine relation based on match filter |
| | | // $relation = ($data['match'] ?? 'all') === 'all' ? 'AND' : 'OR'; |
| | | // |
| | | // $args['tax_query'] = array_merge( |
| | | // ['relation' => $relation], |
| | | // $validFilters |
| | | // ); |
| | | // |
| | | // return $args; |
| | | // } |
| | | |
| | | /** |
| | | * @param WP_REST_Request $request |
| | | * |
| | |
| | | $args['highlight'] = $highlight; |
| | | } |
| | | $cached['items'] = $this->processHighlightedItem($cached['items'], $args); |
| | | $response = new WP_REST_Response($cached); |
| | | $response = $this->success($cached); |
| | | return $this->addCacheHeaders($response); |
| | | } |
| | | // Fetch and format items |
| | | $items = $this->fetchFeedItems($args); |
| | | |
| | | $ttl = (str_contains($args['orderby'], 'RAND')) ? 300 : $this->cache_ttl; |
| | | $ttl = (str_contains($args['orderby'], 'RAND')) ? 300 : $this->cacheTtl; |
| | | $this->cache->set($key, $items, $ttl); |
| | | |
| | | if ($request->get_param('highlight')) { |
| | |
| | | } |
| | | |
| | | $items['items'] = $this->processHighlightedItem($items['items'], $args); |
| | | $response = new WP_REST_Response($items); |
| | | $response = $this->success($items); |
| | | return $this->addCacheHeaders($response); |
| | | } |
| | | |
| | | /** |
| | | * Build cache context from query args |
| | | * Extracts content types and parameters needed for proper cache checking |
| | | * |
| | | * @param array $args Built WP_Query arguments |
| | | * @param WP_REST_Request $request Original request |
| | | * @return array Cache context with content_types and additional_params |
| | | */ |
| | | protected function buildCacheContext(array $args, WP_REST_Request $request): array |
| | | { |
| | | // Extract content types from post_type in args |
| | | $post_types = is_array($args['post_type']) |
| | | ? $args['post_type'] |
| | | : [$args['post_type']]; |
| | | |
| | | $content_types = array_map('jvbNoBase', $post_types); |
| | | $content_types[] = 'feed'; // Always include base feed type |
| | | |
| | | // Build additional params for ETag uniqueness |
| | | $additional_params = [ |
| | | 'order' => $args['orderby'] ?? 'date', |
| | | 'direction' => $args['order'] ?? 'DESC', |
| | | 'page' => $args['paged'] ?? 1, |
| | | ]; |
| | | |
| | | if ($request->get_param('favourites')) { |
| | | $additional_params['user'] = (int)$request->get_param('user'); |
| | | } |
| | | |
| | | // Include author filter if present (from context or favourites) |
| | | if (!empty($args['author'])) { |
| | | $additional_params['author'] = $args['author']; |
| | | } |
| | | |
| | | if (!empty($args['author__in'])) { |
| | | $additional_params['author__in'] = $args['author__in']; |
| | | } |
| | | |
| | | // Include taxonomy filters if present |
| | | if (!empty($args['tax_query'])) { |
| | | $tax_filters = []; |
| | | foreach ($args['tax_query'] as $key => $query) { |
| | | if ($key === 'relation' || !is_array($query)) { |
| | | continue; |
| | | } |
| | | |
| | | $taxonomy = jvbNoBase($query['taxonomy'] ?? ''); |
| | | if ($taxonomy) { |
| | | $tax_filters[$taxonomy] = $query['terms'] ?? []; |
| | | // Also add taxonomy to content_types for timestamp checking |
| | | $content_types[] = $taxonomy; |
| | | } |
| | | } |
| | | if (!empty($tax_filters)) { |
| | | $additional_params['taxonomies'] = $tax_filters; |
| | | } |
| | | } |
| | | |
| | | // Include date filters if present |
| | | if (!empty($args['date_query'])) { |
| | | $additional_params['date_filter'] = md5(serialize($args['date_query'])); |
| | | } |
| | | |
| | | // Include meta queries if present |
| | | if (!empty($args['meta_query'])) { |
| | | $additional_params['meta_filter'] = md5(serialize($args['meta_query'])); |
| | | } |
| | | |
| | | return [ |
| | | 'content_types' => array_unique($content_types), |
| | | 'additional_params' => $additional_params |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * @param array $args Formatted Args for WP_Query |
| | | * @param array $items Formatted Args for WP_Query |
| | | * @param array $data parsed Request Data |
| | | * |
| | | * @return array|null |
| | |
| | | : explode(',', $args['post_type']); |
| | | |
| | | // Check if filtering global feed content |
| | | $globalFeedTypes = array_map('jvbCheckBase', |
| | | array_keys(Features::getTypesWithFeature('show_feed', 'content')) |
| | | ); |
| | | |
| | | if (array_intersect($args['post_type'], $globalFeedTypes)) { |
| | | $artists = jvbGetContentUsers($context['id']); |
| | | if (!empty($artists)) { |
| | | $args['author__in'] = $artists; |
| | | if (in_array($context['type'], jvbGlobalFeedContentTaxonomies())) { |
| | | // Global: show posts from any content type with this taxonomy |
| | | $for_content = JVB_TAXONOMY[$context['type']]['for_content'] ?? []; |
| | | if (empty($for_content)) { |
| | | // Fall back to any content that has this taxonomy registered |
| | | $for_content = array_keys( |
| | | array_filter( |
| | | JVB_CONTENT, |
| | | fn($c) => in_array($context['type'], $c['taxonomies'] ?? []) |
| | | ) |
| | | ); |
| | | } |
| | | } else { |
| | | $args['tax_query'] = [ |
| | | 'relation' => 'AND', |
| | | [ |
| | | 'taxonomy' => BASE . $context['type'], |
| | | 'terms' => $context['id'], |
| | | ] |
| | | ]; |
| | | |
| | | // Convert to full post types with BASE prefix |
| | | $post_types = array_map(fn($type) => BASE . $type, $for_content); |
| | | |
| | | // Filter to only show_feed content types |
| | | $show_feed_types = Features::getTypesWithFeature('show_feed', 'content'); |
| | | $args['post_type'] = array_intersect( |
| | | $post_types, |
| | | array_map(fn($type) => BASE . $type, $show_feed_types) |
| | | ); |
| | | } |
| | | break; |
| | | case taxonomy_exists(jvbCheckBase($context['type'])): |
| | | $args['tax_query'] = [ |
| | | 'relation' => 'AND', |
| | | [ |
| | | 'taxonomy' => BASE . $context['type'], |
| | | 'terms' => $context['id'], |
| | | ] |
| | | |
| | | // Add term to tax query |
| | | $args['tax_query'][] = [ |
| | | 'taxonomy' => jvbCheckBase($context['type']), |
| | | 'field' => 'term_id', |
| | | 'terms' => [(int)$context['id']], |
| | | ]; |
| | | break; |
| | | } |
| | | |
| | | return $args; |
| | | } |
| | | |
| | |
| | | * |
| | | * @return array |
| | | */ |
| | | protected function applyFavouritesFilter(array $args, array $filters): array |
| | | protected function applyFavouritesFilter(array $args, array $data): array |
| | | { |
| | | if (!array_key_exists('favourites', $filters)) { |
| | | if (empty($data['favourites']) || empty($data['user'])) { |
| | | return $args; |
| | | } |
| | | global $wpdb; |
| | | |
| | | // Get post types for the current filter |
| | | $post_types = is_array($args['post_type']) |
| | | ? $args['post_type'] |
| | | : [$args['post_type']]; |
| | | $user_id = (int)$data['user']; |
| | | $content = jvbNoBase($args['post_type']); |
| | | |
| | | $favourites_table = $wpdb->prefix . BASE . 'favourites'; |
| | | $placeholders = implode(',', array_fill(0, count($post_types), '%s')); |
| | | $favourited_ids = $wpdb->get_col($wpdb->prepare( |
| | | "SELECT target_id FROM {$favourites_table} |
| | | WHERE user_id = %d AND type IN ($placeholders)", |
| | | array_merge( |
| | | [get_current_user_id()], |
| | | $post_types |
| | | ) |
| | | )); |
| | | // Get user's favourites for this content type |
| | | $fav_key = BASE . 'favourites_' . $content; |
| | | $favourites = get_user_meta($user_id, $fav_key, true); |
| | | |
| | | if (empty($favourited_ids)) { |
| | | // Force empty results |
| | | if (empty($favourites)) { |
| | | // No favourites - return empty result |
| | | $args['post__in'] = [0]; // Will return no results |
| | | return $args; |
| | | } |
| | | |
| | | $fav_ids = array_filter(array_map('intval', explode(',', $favourites))); |
| | | |
| | | if (empty($fav_ids)) { |
| | | $args['post__in'] = [0]; |
| | | return $args; |
| | | } |
| | | |
| | | $args['post__in'] = isset($args['post__in']) |
| | | ? array_intersect($args['post__in'], $favourited_ids) |
| | | : $favourited_ids; |
| | | $args['post__in'] = $fav_ids; |
| | | $args['orderby'] = 'post__in'; // Preserve favourite order |
| | | |
| | | return $args; |
| | | } |
| | |
| | | |
| | | $feedTypes = $this->buildFeedTypesConfig(); |
| | | |
| | | $response = new WP_REST_Response($feedTypes); |
| | | $response = $this->success($feedTypes); |
| | | return $this->addCacheHeaders($response); |
| | | } |
| | | |
| | |
| | | <?php |
| | | namespace JVBase\rest\routes; |
| | | |
| | | use JVBase\rest\RestRouteManager; |
| | | use JVBase\meta\Sanitizer; |
| | | use JVBase\meta\Validator; |
| | | use JVBase\rest\PermissionHandler; |
| | | use JVBase\rest\Rest; |
| | | use JVBase\managers\Cache; |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\blocks\FormBlock; |
| | | use JVBase\rest\Route; |
| | | use JVBase\utility\Features; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | |
| | | * |
| | | * Handles REST API endpoints for form submissions |
| | | */ |
| | | class FormRoutes extends RestRouteManager |
| | | class FormRoutes extends Rest |
| | | { |
| | | protected Cache $cache; |
| | | protected FormBlock $form_block; |
| | | |
| | | public function __construct() |
| | | { |
| | | $this->cacheName = 'forms'; |
| | | $this->cacheTtl = HOUR_IN_SECONDS; |
| | | parent::__construct(); |
| | | $this->action = 'form-'; |
| | | $this->cache = Cache::for('forms', HOUR_IN_SECONDS); |
| | | |
| | | |
| | | // Add query vars |
| | | add_filter('query_vars', [$this, 'addQueryVars']); |
| | |
| | | */ |
| | | public function registerRoutes(): void |
| | | { |
| | | // Form submission endpoint |
| | | register_rest_route($this->namespace, '/forms', [ |
| | | [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'submitForm'], |
| | | 'permission_callback' => [$this, 'checkRateLimit'], // Public endpoint, rate limited |
| | | ], |
| | | [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getForms'], |
| | | 'permission_callback' => [$this, 'checkPermission'] |
| | | ] |
| | | ]); |
| | | // ['actionNonce'=>'dash-'] |
| | | Route::for('forms') |
| | | ->post([$this, 'submitForm']) |
| | | ->args([ |
| | | 'form_type' => 'string|required', |
| | | 'form_id' => 'string|required', |
| | | 'timestamp' => 'string', |
| | | 'cf-turnstile-response' => 'string', |
| | | ]) |
| | | ->auth('public') |
| | | ->rateLimit(5) // 5 submissions per minute |
| | | ->get([$this, 'getForms']) |
| | | ->auth(PermissionHandler::combine(['logged_in', ['actionNonce'=>'dash-']])) |
| | | ->rateLimit(30); |
| | | |
| | | // Get specific form configuration |
| | | register_rest_route($this->namespace, '/forms/(?P<form_type>[a-zA-Z0-9_-]+)', [ |
| | | [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getForm'], |
| | | 'permission_callback' => [$this, 'checkPermission'], |
| | | 'args' => [ |
| | | 'form_type' => [ |
| | | 'required' => true, |
| | | 'type' => 'string', |
| | | 'sanitize_callback' => 'sanitize_text_field' |
| | | ] |
| | | ] |
| | | ] |
| | | ]); |
| | | Route::for(Route::pattern('forms/{form_type}')) |
| | | ->get([$this, 'getForm']) |
| | | ->arg('form_type', 'string|required') |
| | | ->auth('logged_in') |
| | | ->rateLimit(30); |
| | | } |
| | | |
| | | /** |
| | |
| | | $result = $this->handleFormSubmission($form_type, $form_id, $form_data, $files); |
| | | |
| | | if (is_wp_error($result)) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => $result->get_error_message() |
| | | ]); |
| | | return $this->error( |
| | | $result->get_error_message(), |
| | | $result->get_error_code(), |
| | | 400 |
| | | ); |
| | | } |
| | | if (array_key_exists('success', $result)){ |
| | | return new WP_REST_Response($result); |
| | | return $this->validationError($result); |
| | | } |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'data' => $result |
| | | ], 200); |
| | | return $this->success($result); |
| | | |
| | | } catch (Exception $e) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'An error occurred while processing your submission.' |
| | | ], 500); |
| | | $this->logError('Form submission error', [ |
| | | 'message' => $e->getMessage(), |
| | | 'trace' => $e->getTraceAsString() |
| | | ]); |
| | | |
| | | return $this->error( |
| | | 'An error occurred while processing your submission.', |
| | | 'submission_error', |
| | | 500 |
| | | ); |
| | | } |
| | | } |
| | | |
| | |
| | | */ |
| | | protected function validateAndSanitizeData(array $form_config, array $form_data): array|WP_REST_Response |
| | | { |
| | | $meta = new MetaManager(null, 'form'); |
| | | $validator = new Validator(); |
| | | $sanitizer = new Sanitizer(); |
| | | |
| | | $processed_data = []; |
| | | $errors = []; |
| | | |
| | |
| | | $field_config['name'] = $field_name; |
| | | |
| | | // Validate field |
| | | if (!$meta->validator->validate($value, $field_config)) { |
| | | if (!$validator->validate($value, $field_config)) { |
| | | $label = $field_config['label'] ?? ucfirst(str_replace('_', ' ', $field_name)); |
| | | $errors['errors'][$field_name] = [ |
| | | 'message' => sprintf('Field "%s" contains invalid data.', $label) |
| | |
| | | } |
| | | |
| | | // Sanitize field |
| | | $processed_data[$field_name] = $meta->sanitizer->sanitize($value, $field_config); |
| | | $processed_data[$field_name] = $sanitizer->sanitize($value, $field_config); |
| | | } |
| | | |
| | | if (!empty($errors)) { |
| | |
| | | $submitter_name = $form_data['name']; |
| | | } |
| | | |
| | | if (!array_key_exists('preheader', $form_config)) { |
| | | if (array_key_exists('preheader', $form_config)) { |
| | | $preheader = $form_config['preheader']; |
| | | } else { |
| | | $submitter_name = $submitter_name?:'website visitor'; |
| | |
| | | ]; |
| | | } |
| | | |
| | | return new WP_REST_Response($public_forms, 200); |
| | | return $this->success($public_forms); |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | public function getForm(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $form_type = $request->get_param('form_type'); |
| | | $form_type = sanitize_text_field($request->get_param('form_type')); |
| | | $form_config = FormBlock::getForm($form_type); |
| | | |
| | | if (!$form_config) { |
| | | return new WP_REST_Response([ |
| | | 'error' => 'Form not found' |
| | | ], 404); |
| | | return $this->notFound('Form not found'); |
| | | } |
| | | |
| | | // Remove sensitive data |
| | | unset($form_config['email_to']); |
| | | |
| | | return new WP_REST_Response($form_config, 200); |
| | | return $this->success($form_config); |
| | | } |
| | | } |
| | |
| | | <?php |
| | | |
| | | namespace JVBase\routes; |
| | | namespace JVBase\rest\routes; |
| | | |
| | | use JVBase\managers\JaneClientImporter; |
| | | use JVBase\managers\JaneSalesImporter; |
| | | use JVBase\importers\JaneAppClientImporter; |
| | | use JVBase\importers\JaneAppSalesImporter; |
| | | use JVBase\rest\Rest; |
| | | use JVBase\rest\Route; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | use WP_Error; |
| | |
| | | * |
| | | * REST API endpoints for importing JaneApp data |
| | | */ |
| | | class JaneImportRoutes |
| | | class ImporterRoutes extends Rest |
| | | { |
| | | protected string $namespace; |
| | | |
| | | public function __construct() |
| | | { |
| | | $this->namespace = BASE . 'v1'; |
| | | } |
| | | |
| | | /** |
| | | * Register REST routes |
| | | */ |
| | | public function registerRoutes(): void |
| | | { |
| | | // Client import endpoint |
| | | register_rest_route($this->namespace, '/jane/import-clients', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'importClients'], |
| | | 'permission_callback' => [$this, 'checkAdminPermission'], |
| | | 'args' => [ |
| | | 'file' => [ |
| | | 'required' => true, |
| | | 'description' => 'CSV file containing client data' |
| | | ], |
| | | 'options' => [ |
| | | 'required' => false, |
| | | 'default' => [], |
| | | 'description' => 'Import options' |
| | | ] |
| | | ] |
| | | ]); |
| | | Route::for('jane/import-clients') |
| | | ->post([$this, 'importClients']) |
| | | ->args([ |
| | | 'options' => 'string', // JSON string of options |
| | | ]) |
| | | ->auth('admin') |
| | | ->rateLimit(3, 300); // 3 imports per 5 minutes |
| | | |
| | | // Sales import endpoint |
| | | register_rest_route($this->namespace, '/jane/import-sales', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'importSales'], |
| | | 'permission_callback' => [$this, 'checkAdminPermission'], |
| | | 'args' => [ |
| | | 'file' => [ |
| | | 'required' => true, |
| | | 'description' => 'CSV file containing sales data' |
| | | ], |
| | | 'options' => [ |
| | | 'required' => false, |
| | | 'default' => [], |
| | | 'description' => 'Import options' |
| | | ] |
| | | ] |
| | | ]); |
| | | Route::for('jane/import-sales') |
| | | ->post([$this, 'importSales']) |
| | | ->args([ |
| | | 'options' => 'string', // JSON string of options |
| | | ]) |
| | | ->auth('admin') |
| | | ->rateLimit(3, 300); // 3 imports per 5 minutes |
| | | |
| | | // Get import status |
| | | register_rest_route($this->namespace, '/jane/import-status/(?P<id>[\w-]+)', [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getImportStatus'], |
| | | 'permission_callback' => [$this, 'checkAdminPermission'] |
| | | ]); |
| | | Route::for(Route::pattern('jane/import-status/{id}')) |
| | | ->get([$this, 'getImportStatus']) |
| | | ->arg('id', 'string|required') |
| | | ->auth('admin') |
| | | ->rateLimit(30, 60); |
| | | } |
| | | |
| | | /** |
| | |
| | | * Import clients from CSV |
| | | * |
| | | * @param WP_REST_Request $request |
| | | * @return WP_REST_Response|WP_Error |
| | | * @return WP_REST_Response |
| | | */ |
| | | public function importClients(WP_REST_Request $request) |
| | | public function importClients(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | // Get uploaded file |
| | | $files = $request->get_file_params(); |
| | | if (empty($files['file'])) { |
| | | return new WP_Error('no_file', 'No file uploaded', ['status' => 400]); |
| | | return $this->error('No file uploaded', 'no_file', 400); |
| | | } |
| | | |
| | | $file = $files['file']; |
| | | |
| | | // Validate file type |
| | | if (!$this->isValidCSV($file)) { |
| | | return new WP_Error('invalid_file', 'Invalid file type. Please upload a CSV file.', ['status' => 400]); |
| | | return $this->error('Invalid file type. Please upload a CSV file.', 'invalid_file', 400); |
| | | } |
| | | |
| | | // Get options |
| | | $options = $request->get_param('options') ?: []; |
| | | // Get and parse options |
| | | $options_param = $request->get_param('options'); |
| | | $options = !empty($options_param) ? json_decode($options_param, true) : []; |
| | | |
| | | $default_options = [ |
| | | 'update_existing' => true, |
| | | 'create_users' => true, |
| | |
| | | $options = wp_parse_args($options, $default_options); |
| | | |
| | | // Process import |
| | | $importer = new JaneClientImporter(); |
| | | $importer = new JaneAppClientImporter(); |
| | | $results = $importer->importFromCSV($file['tmp_name'], $options); |
| | | |
| | | if (is_wp_error($results)) { |
| | | return new WP_Error( |
| | | 'import_failed', |
| | | $this->logError('Client import failed', [ |
| | | 'error' => $results->get_error_message(), |
| | | 'file' => $file['name'] |
| | | ]); |
| | | |
| | | return $this->error( |
| | | $results->get_error_message(), |
| | | ['status' => 500] |
| | | 'import_failed', |
| | | 500 |
| | | ); |
| | | } |
| | | |
| | |
| | | 'completed_at' => current_time('mysql') |
| | | ], HOUR_IN_SECONDS); |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | return $this->success([ |
| | | 'import_id' => $import_id, |
| | | 'results' => $results, |
| | | 'summary' => $this->generateClientImportSummary($results) |
| | | ], 200); |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Import sales from CSV |
| | | * |
| | | * @param WP_REST_Request $request |
| | | * @return WP_REST_Response|WP_Error |
| | | * @return WP_REST_Response |
| | | */ |
| | | public function importSales(WP_REST_Request $request) |
| | | public function importSales(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | // Get uploaded file |
| | | $files = $request->get_file_params(); |
| | | if (empty($files['file'])) { |
| | | return new WP_Error('no_file', 'No file uploaded', ['status' => 400]); |
| | | return $this->error('No file uploaded', 'no_file', 400); |
| | | } |
| | | |
| | | $file = $files['file']; |
| | | |
| | | // Validate file type |
| | | if (!$this->isValidCSV($file)) { |
| | | return new WP_Error('invalid_file', 'Invalid file type. Please upload a CSV file.', ['status' => 400]); |
| | | return $this->error('Invalid file type. Please upload a CSV file.', 'invalid_file', 400); |
| | | } |
| | | |
| | | // Get options |
| | | $options = $request->get_param('options') ?: []; |
| | | // Get and parse options |
| | | $options_param = $request->get_param('options'); |
| | | $options = !empty($options_param) ? json_decode($options_param, true) : []; |
| | | |
| | | $default_options = [ |
| | | 'skip_existing' => true |
| | | ]; |
| | | $options = wp_parse_args($options, $default_options); |
| | | |
| | | // Process import |
| | | $importer = new JaneSalesImporter(); |
| | | $importer = new JaneAppSalesImporter(); |
| | | $results = $importer->importFromCSV($file['tmp_name'], $options); |
| | | |
| | | if (is_wp_error($results)) { |
| | | return new WP_Error( |
| | | 'import_failed', |
| | | $this->logError('Sales import failed', [ |
| | | 'error' => $results->get_error_message(), |
| | | 'file' => $file['name'] |
| | | ]); |
| | | |
| | | return $this->error( |
| | | $results->get_error_message(), |
| | | ['status' => 500] |
| | | 'import_failed', |
| | | 500 |
| | | ); |
| | | } |
| | | |
| | |
| | | 'completed_at' => current_time('mysql') |
| | | ], HOUR_IN_SECONDS); |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | return $this->success([ |
| | | 'import_id' => $import_id, |
| | | 'results' => $results, |
| | | 'summary' => $this->generateSalesImportSummary($results) |
| | | ], 200); |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Get import status by ID |
| | | * |
| | | * @param WP_REST_Request $request |
| | | * @return WP_REST_Response|WP_Error |
| | | * @return WP_REST_Response |
| | | */ |
| | | public function getImportStatus(WP_REST_Request $request) |
| | | public function getImportStatus(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $import_id = $request->get_param('id'); |
| | | $import_id = sanitize_text_field($request->get_param('id')); |
| | | $import_data = get_transient('jane_import_' . $import_id); |
| | | |
| | | if (!$import_data) { |
| | | return new WP_Error( |
| | | 'import_not_found', |
| | | 'Import not found or expired', |
| | | ['status' => 404] |
| | | ); |
| | | return $this->notFound('Import not found or expired'); |
| | | } |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'data' => $import_data |
| | | ], 200); |
| | | return $this->success($import_data); |
| | | } |
| | | |
| | | /** |
| | |
| | | <?php |
| | | namespace JVBase\rest\routes; |
| | | |
| | | use JVBase\rest\RestRouteManager; |
| | | use JVBase\rest\Rest; |
| | | use JVBase\rest\Route; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | use Exception; |
| | |
| | | exit; // Exit if accessed directly |
| | | } |
| | | |
| | | class IntegrationsRoutes extends RestRouteManager |
| | | class IntegrationsRoutes extends Rest |
| | | { |
| | | |
| | | /** |
| | |
| | | */ |
| | | public function registerRoutes(): void |
| | | { |
| | | register_rest_route($this->namespace, '/integrations', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleAction'], |
| | | 'permission_callback' => [$this, 'checkPermission'], |
| | | 'args' => [ |
| | | 'service' => [ |
| | | 'required' => true, |
| | | 'type' => 'string', |
| | | 'enum' => JVB()->getAvailableServices() |
| | | ], |
| | | 'action' => [ |
| | | 'required' => true, |
| | | 'sanitize_callback' => 'sanitize_text_field', |
| | | ], |
| | | 'user_id' => [ |
| | | 'required' => false, |
| | | 'sanitize_callback' => 'absint', |
| | | ], |
| | | 'context' => [ |
| | | 'required' => false, |
| | | 'default' => 'user', |
| | | 'sanitize_callback' => 'sanitize_text_field', |
| | | 'validate_callback' => function($param) { |
| | | return in_array($param, ['admin', 'user']); |
| | | } |
| | | ], |
| | | 'data' => [ |
| | | 'required' => false, |
| | | 'default' => [], |
| | | ] |
| | | ] |
| | | ]); |
| | | |
| | | // register_rest_route($this->namespace, '/oauth/callback', [ |
| | | // 'methods' => 'GET', |
| | | // 'callback' => [$this, 'handleOAuthCallback'], |
| | | // 'permission_callback' => '__return_true', // External service callback |
| | | // 'args' => [ |
| | | // 'service' => [ |
| | | // 'required' => true, |
| | | // 'sanitize_callback' => 'sanitize_text_field', |
| | | // ], |
| | | // 'code' => [ |
| | | // 'required' => false, |
| | | // 'sanitize_callback' => 'sanitize_text_field', |
| | | // ], |
| | | // 'state' => [ |
| | | // 'required' => false, |
| | | // 'sanitize_callback' => 'sanitize_text_field', |
| | | // ], |
| | | // 'error' => [ |
| | | // 'required' => false, |
| | | // 'sanitize_callback' => 'sanitize_text_field', |
| | | // ] |
| | | // ] |
| | | // ]); |
| | | |
| | | // Add OAuth initiation route (for AJAX calls) |
| | | register_rest_route($this->namespace, '/oauth/connect', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'initiateOAuth'], |
| | | 'permission_callback' => [$this, 'checkPermissions'], |
| | | 'args' => [ |
| | | 'service' => [ |
| | | 'required' => true, |
| | | 'sanitize_callback' => 'sanitize_text_field', |
| | | ], |
| | | 'user_id' => [ |
| | | 'required' => false, |
| | | 'sanitize_callback' => 'absint', |
| | | ], |
| | | 'return_url' => [ |
| | | 'required' => false, |
| | | 'sanitize_callback' => 'esc_url_raw', |
| | | ] |
| | | ] |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Check permissions based on context |
| | | */ |
| | | public function checkPermission(\WP_REST_Request $request): bool |
| | | { |
| | | parent::checkPermission($request); |
| | | $context = $request->get_param('context') ?? 'user'; |
| | | $user_id = $request->get_param('user_id'); |
| | | |
| | | // Admin context requires manage_options |
| | | if ($context === 'admin') { |
| | | return current_user_can('manage_options'); |
| | | } |
| | | |
| | | // User context |
| | | if (!is_user_logged_in()) { |
| | | return false; |
| | | } |
| | | |
| | | $current_user_id = get_current_user_id(); |
| | | |
| | | // If user_id provided, verify it matches current user |
| | | // OR current user is admin |
| | | if ($user_id && $user_id != $current_user_id) { |
| | | return current_user_can('manage_options'); |
| | | } |
| | | |
| | | return true; |
| | | Route::for('integrations') |
| | | ->post([$this, 'handleAction']) |
| | | ->args([ |
| | | 'service' => 'string|required|enum:'.implode(',',JVB()->getAvailableServices()), |
| | | 'action' => 'string|required', |
| | | 'user_id' => 'int', |
| | | 'context' => 'string|enum:admin,user', |
| | | 'data' => 'array' |
| | | ]) |
| | | ->auth('user') |
| | | ->rateLimit(20); |
| | | Route::for('oath/connect') |
| | | ->post([$this, 'initiateOAuth']) |
| | | ->auth('user') |
| | | ->rateLimit(20) |
| | | ->args([ |
| | | 'service' => 'string|required', |
| | | 'user_id' => 'int', |
| | | 'return_url'=> 'url' |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | |
| | | $service = $request->get_param('service'); |
| | | $action = $request->get_param('action'); |
| | | |
| | | // Get the integration instance |
| | | $userID = absint($request->get_param('user_id')); |
| | | if (!$this->userCheck($userID)) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Invalid User' |
| | | ]); |
| | | } |
| | | |
| | | $theUserID = (user_can($userID, 'manage_options')) ? null : $userID; |
| | | $theUserID = (user_can($request->get_param('user'), 'manage_options')) ? null : $request->get_param('user'); |
| | | $integration = JVB()->connect($service, $theUserID); |
| | | |
| | | if (!$integration) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Service not found' |
| | | ], 404); |
| | | return $this->validationError(['message'=>'Invalid service']); |
| | | } |
| | | |
| | | |
| | | $integration->getCredentials(); |
| | | |
| | | // Handle the action |
| | | try { |
| | | // Get data parameter - DON'T convert empty array to null |
| | | $data = $request->get_param('data'); |
| | | |
| | | // Only set to null if it's truly empty or not provided |
| | | if (!is_array($data) && empty($data)) { |
| | | $data = null; |
| | | } |
| | | |
| | | error_log('[IntegrationsRoutes] Calling processAction with data: ' . print_r($data, true)); |
| | | |
| | | $result = $integration->processAction($action, $data); |
| | | |
| | | return new WP_REST_Response($result, 200); |
| | | return $this->success($result); |
| | | |
| | | } catch (Exception $e) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => $e->getMessage() |
| | | ], 400); |
| | | return $this->error($e->getMessage()); |
| | | } |
| | | } |
| | | |
| | | public function initiateOAuth(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $service = $request->get_param('service'); |
| | | $user_id = $request->get_param('user_id') ?: get_current_user_id(); |
| | | $user_id = $request->get_param('user_id'); |
| | | $return_url = $request->get_param('return_url'); |
| | | |
| | | $integration = JVB()->connect($service, $user_id); |
| | | |
| | | if (!$integration || !$integration->isOAuthService) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Invalid OAuth service' |
| | | ], 400); |
| | | return $this->validationError(['message'=>'Invalid service']); |
| | | } |
| | | |
| | | $auth_url = $integration->getOAuthUrl($return_url); |
| | | |
| | | if ($auth_url) { |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'auth_url' => $auth_url, |
| | | 'popup' => true |
| | | ], 200); |
| | | return $this->success($auth_url); |
| | | } |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Failed to generate authorization URL' |
| | | ], 400); |
| | | } |
| | | |
| | | /** |
| | | * Handle OAuth callback from external service |
| | | */ |
| | | public function handleOAuthCallback(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $service = $request->get_param('service'); |
| | | $code = $request->get_param('code'); |
| | | $state = $request->get_param('state'); |
| | | $error = $request->get_param('error'); |
| | | |
| | | error_log('OAuth Callback - Service: ' . $request->get_param('service')); |
| | | error_log('OAuth Callback - Code: ' . $request->get_param('code')); |
| | | error_log('OAuth Callback - State: ' . $request->get_param('state')); |
| | | error_log('OAuth Callback - Error: ' . $request->get_param('error')); |
| | | |
| | | |
| | | $state_parts = explode('|', $state); |
| | | $state_key = $state_parts[0] ?? ''; |
| | | $user_id = intval($state_parts[1] ?? 0); |
| | | $user_id = ($user_id === 0) ? null : $user_id; |
| | | $return_url = isset($state_parts[2]) ? base64_decode($state_parts[2]) : admin_url('admin.php?page=jvb-integrations'); |
| | | |
| | | |
| | | $state_data = get_transient('oauth_state_' . $state_key); |
| | | error_log('State Data: '.print_r($state_data, true)); |
| | | if (!$state_data || $state_data['service'] !== $service) { |
| | | wp_die('Invalid state parameter', 'OAuth Error'); |
| | | } |
| | | |
| | | // Delete the transient to prevent reuse |
| | | delete_transient('oauth_state_' . $state_key); |
| | | error_log('Return URL: '.print_r($return_url, true)); |
| | | // Handle error from OAuth provider |
| | | if ($error) { |
| | | $error_description = $request->get_param('error_description') ?? 'Authorization denied'; |
| | | |
| | | wp_redirect(add_query_arg([ |
| | | 'page' => 'jvb-integrations', |
| | | 'error' => 'OAuth authorization denied: ' . $error_description |
| | | ], $return_url)); |
| | | exit; |
| | | } |
| | | |
| | | // Get integration instance |
| | | error_log('User ID: '.print_r($user_id, true)); |
| | | error_log('Service: '.print_r($service, true)); |
| | | $integration = JVB()->connect($service, $user_id); |
| | | |
| | | if (!$integration) { |
| | | wp_die('Invalid service: ' . esc_html($service), 'OAuth Error'); |
| | | } |
| | | |
| | | |
| | | // Exchange code for tokens |
| | | $result = $integration->handleOAuthCode($code, $state); |
| | | |
| | | // Redirect back with result |
| | | if ($result['success']) { |
| | | wp_redirect(add_query_arg([ |
| | | 'page' => 'jvb-integrations', |
| | | 'success' => 'Successfully connected to ' . $integration->title |
| | | ], $return_url)); |
| | | } else { |
| | | // Handle failure |
| | | $error_message = $result['message'] ?? 'Failed to complete OAuth authorization'; |
| | | |
| | | wp_redirect(add_query_arg([ |
| | | 'page' => 'jvb-integrations', |
| | | 'error' => $error_message |
| | | ], $return_url)); |
| | | } |
| | | exit; |
| | | return $this->error('Failed to generate authorization URL'); |
| | | } |
| | | } |
| | |
| | | namespace JVBase\rest\routes; |
| | | |
| | | |
| | | use JVBase\rest\RestRouteManager; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\rest\Rest; |
| | | use Exception; |
| | | use JVBase\rest\Route; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | use Exception; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; // Exit if accessed directly |
| | | } |
| | | |
| | | class IntegrationsSquareRoutes extends RestRouteManager |
| | | class IntegrationsSquareRoutes extends Rest |
| | | { |
| | | public function registerRoutes():void |
| | | { |
| | | register_rest_route('jvb/v1/square', '/process-payment', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handlePaymentProcessing'], |
| | | 'permission_callback' => '__return_true' // Adjust based on your auth |
| | | ]); |
| | | Route::for('square/process-payment') |
| | | ->post([$this, 'handlePaymentProcessing']) |
| | | ->auth('public') |
| | | ->rateLimit(2); |
| | | |
| | | register_rest_route('jvb/v1/square', '/saved-cards', [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getSavedCards'], |
| | | 'permission_callback' => 'is_user_logged_in' |
| | | ]); |
| | | Route::for('square/saved-cards') |
| | | ->post([$this, 'getSavedCards']) |
| | | ->auth('user') |
| | | ->rateLimit(5); |
| | | |
| | | Route::for('square/order-history') |
| | | ->get([$this, 'getOrderHistory']) |
| | | ->auth('user') |
| | | ->rateLimit(5); |
| | | |
| | | register_rest_route('jvb/v1/square', '/order-history', [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getOrderHistory'], |
| | | 'permission_callback' => 'is_user_logged_in' |
| | | ]); |
| | | |
| | | |
| | | register_rest_route('jvb/v1/square', '/order-status/(?P<order_id>[a-zA-Z0-9_-]+)', [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getOrderStatus'], |
| | | 'permission_callback' => '__return_true' // Allow guests with order ID |
| | | ]); |
| | | Route::for(Route::pattern('square/order-status/{order_id}')) |
| | | ->get([$this, 'getOrderStatus']) |
| | | ->auth('public') |
| | | ->rateLimit(20); |
| | | } |
| | | |
| | | public function handlePaymentProcessing($request): array |
| | | //TODO: Are we processing this through our server at all? Or is it in the javascript going straight to square? |
| | | public function handlePaymentProcessing($request):WP_REST_Response |
| | | { |
| | | $data = $request->get_json_params(); |
| | | |
| | |
| | | // This ensures retries use SAME key |
| | | $cart_id = $data['cart_id'] ?? ''; |
| | | if (!$cart_id) { |
| | | return ['success' => false, 'message' => 'Missing cart ID']; |
| | | return $this->validationError(['message'=>'Missing cart ID']); |
| | | } |
| | | |
| | | // Check if we already processed this cart |
| | | $existing_order = get_transient(BASE . 'cart_order_' . $cart_id); |
| | | if ($existing_order) { |
| | | // Return cached result - prevents double charge |
| | | return $existing_order; |
| | | return $this->success($existing_order); |
| | | } |
| | | |
| | | // Generate idempotency key tied to this specific cart |
| | | $idempotency_key = 'cart_' . $cart_id . '_' . time(); |
| | | |
| | | // Store key to prevent reprocessing |
| | | //TODO: Should we just use our Cache.php? |
| | | set_transient(BASE . 'cart_idempotency_' . $cart_id, $idempotency_key, HOUR_IN_SECONDS); |
| | | |
| | | // Validate required fields |
| | | $required = ['source_id', 'amount', 'items', 'customer']; |
| | | foreach ($required as $field) { |
| | | if (empty($data[$field])) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => "Missing required field: {$field}" |
| | | ]; |
| | | return $this->validationError(['message' => "Missing required field: {$field}"]); |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | set_transient(BASE . 'cart_order_' . $cart_id, $result, HOUR_IN_SECONDS); |
| | | |
| | | return $result; |
| | | return $this->success($result); |
| | | |
| | | } catch (Exception $e) { |
| | | $this->logError('Payment processing failed', [ |
| | | 'error' => $e->getMessage(), |
| | | 'idempotency_key' => $data['idempotency_key'] |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => $e->getMessage() |
| | | ]; |
| | | return $this->error($e->getMessage()); |
| | | } |
| | | } |
| | | |
| | | public function getSavedCards($request): array |
| | | public function getSavedCards(WP_REST_Request $request):WP_REST_Response |
| | | { |
| | | $user_id = get_current_user_id(); |
| | | if (!$user_id) { |
| | | return ['success' => false, 'message' => 'Not logged in']; |
| | | $data = $request->get_params(); |
| | | $user_id = absint($data['user']??0); |
| | | if ($user_id === 0) { |
| | | return $this->validationError(['message' => 'Not logged in']); |
| | | } |
| | | |
| | | $square = JVB()->connect('square'); |
| | |
| | | $square_customer_id = get_user_meta($user_id, BASE . '_square_customer_id', true); |
| | | |
| | | if (!$square_customer_id) { |
| | | return ['success' => true, 'cards' => []]; |
| | | return $this->success(['cards' => []]); |
| | | } |
| | | |
| | | // Fetch cards from Square (2025-compliant - separate endpoint) |
| | | $cards_response = $square->getRequest('cards?customer_id=' . $square_customer_id); |
| | | |
| | | if (is_wp_error($cards_response)) { |
| | | return ['success' => false, 'message' => 'Failed to fetch cards']; |
| | | return $this->error('Failed to fetch cards'); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'cards' => $cards_response['cards'] ?? [] |
| | | ]; |
| | | return $this->success(['cards' => $cards_response['cards']??[]]); |
| | | } |
| | | |
| | | public function getOrderHistory($request): array |
| | | public function getOrderHistory(WP_REST_Request $request):WP_REST_Response |
| | | { |
| | | $user_id = get_current_user_id(); |
| | | if (!$user_id) { |
| | | return ['success' => false, 'message' => 'Not logged in']; |
| | | $data = $request->get_params(); |
| | | $user_id = absint($data['user']??0); |
| | | if ($user_id === 0) { |
| | | return $this->validationError(['message' => 'Not logged in']); |
| | | } |
| | | |
| | | // Get orders from custom post type |
| | |
| | | |
| | | $order_data = []; |
| | | foreach ($orders as $order) { |
| | | $meta = new \JVBase\meta\MetaManager($order->ID, 'post'); |
| | | $order_data[] = [ |
| | | $meta = Meta::forPost($order->ID); |
| | | $fields = $meta->getAll(['square_order_id', 'status', 'amount', 'items', 'created_at', 'pickup_time']); |
| | | $order_data[] = array_merge([ |
| | | 'wp_order_id' => $order->ID, |
| | | 'square_order_id' => $meta->getValue('square_order_id'), |
| | | 'status' => $meta->getValue('status'), |
| | | 'amount' => $meta->getValue('amount'), |
| | | 'items' => $meta->getValue('items'), |
| | | 'created_at' => $meta->getValue('created_at'), |
| | | 'pickup_time' => $meta->getValue('pickup_time') |
| | | ]; |
| | | ], $fields); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'orders' => $order_data |
| | | ]; |
| | | return $this->success(['orders' => $order_data]); |
| | | } |
| | | |
| | | public function getOrderStatus($request): array |
| | | public function getOrderStatus(WP_REST_Request $request):WP_REST_Response |
| | | { |
| | | $order_id = $request->get_param('order_id'); |
| | | |
| | |
| | | $wp_order_id = get_option(BASE . 'square_order_map_' . $order_id); |
| | | |
| | | if (!$wp_order_id) { |
| | | return ['success' => false, 'message' => 'Order not found']; |
| | | return $this->error('Order not found'); |
| | | } |
| | | |
| | | $meta = new \JVBase\meta\MetaManager($wp_order_id, 'post'); |
| | | $meta = Meta::forPost($wp_order_id); |
| | | $fields = $meta->getAll(['status', 'fulfillment_status', 'pickup_time', 'items']); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'order' => [ |
| | | 'status' => $meta->getValue('status'), |
| | | 'fulfillment_status' => $meta->getValue('fulfillment_status'), |
| | | 'pickup_time' => $meta->getValue('pickup_time'), |
| | | 'items' => $meta->getValue('items') |
| | | ] |
| | | ]; |
| | | return $this->success(['order' => $fields]); |
| | | } |
| | | } |
| | |
| | | <?php |
| | | namespace JVBase\rest\routes; |
| | | |
| | | use JVBase\JVB; |
| | | use JVBase\rest\RestRouteManager; |
| | | use Exception; |
| | | use JVBase\utility\Features; |
| | | use JVBase\rest\Rest; |
| | | use JVBase\managers\CustomTable; |
| | | use JVBase\rest\Route; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | use WP_Error; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; // Exit if accessed directly |
| | | exit; |
| | | } |
| | | // TODO: Get this to work with the constants setup |
| | | /*** |
| | | * WORKFLOW: |
| | | * 1) Verified user (userA) invites user |
| | | * a) USER EXISTS -> notify user they're already up |
| | | * b) USER DOESN'T EXIST: |
| | | * i) check if user exists in invitation table |
| | | * ii) if they exist, add userA to inviters |
| | | * - if status is expired, resent email invite and set status to 'pending' |
| | | * iii) if they don't exist, add to table |
| | | * iv) once invited user registers: |
| | | * - set status to 'accepted', add new_user_id |
| | | * - set user as verified |
| | | * - if user was invited to a specific shop, pass user along to that shop |
| | | |
| | | /** |
| | | * Invitations Route Manager |
| | | * |
| | | * Handles user invitations: |
| | | * - Global invitations (user to user, based on JVB_MEMBERSHIP['can_invite']) |
| | | * - Term invitations (to ownable content taxonomies with 'invitable' flag) |
| | | */ |
| | | class Invitations extends RestRouteManager |
| | | class Invitations extends Rest |
| | | { |
| | | protected string $tableName; |
| | | protected array $inviteTypes; |
| | | protected $wpdb; |
| | | protected string $prefix; |
| | | protected array $tableNames; |
| | | protected int $expiryDays = 14; // Invitations expire after 14 days |
| | | protected array $inviteConfig; |
| | | protected CustomTable $table; |
| | | |
| | | public function __construct() |
| | | { |
| | | $this->cache_name = 'invitations'; |
| | | parent::__construct(); |
| | | global $wpdb; |
| | | $this->inviteTypes = jvbInviteTableTypes(); |
| | | $this->tableNames = jvbInviteTables(); |
| | | $this->wpdb = $wpdb; |
| | | $this->prefix = $wpdb->prefix; |
| | | public function __construct() |
| | | { |
| | | $this->cacheName = 'invitations'; |
| | | parent::__construct(); |
| | | |
| | | // Add hooks for processing accepted invitations |
| | | add_action('user_register', [$this, 'checkInvitation'], 10, 1); |
| | | // Get invitation configuration |
| | | $this->inviteConfig = JVB()->invitations()->getInviteConfig(); |
| | | $this->table = CustomTable::for('invitations'); |
| | | |
| | | |
| | | add_filter('jvbLoginLabels', [$this, 'modifyLoginLabels'], 10, 2); |
| | | |
| | | |
| | | |
| | | add_action('jvb_daily_maintenance', [$this, 'cleanupExpiredInvitations']); |
| | | |
| | | // Add filter for bulk operation handling |
| | | add_filter(BASE . 'handle_bulk_operation', [ $this, 'processOperation' ], 10, 3); |
| | | } |
| | | |
| | | /** |
| | | * Registers the routes for invitations |
| | | * @return void |
| | | */ |
| | | public function registerRoutes():void |
| | | { |
| | | register_rest_route($this->namespace, '/invitations', [ |
| | | [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getInvitations'], |
| | | 'permission_callback' => [$this, 'checkPermission'] |
| | | ], |
| | | [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'createInvitationRequest'], |
| | | 'permission_callback' => [$this, 'checkPermission'] |
| | | ] |
| | | ]); |
| | | } |
| | | |
| | | protected function buildParams(object $request):array |
| | | { |
| | | $data = $request->get_params(); |
| | | $role = (array_key_exists('role', $data) && array_key_exists($data['role'], $this->tableNames)) ? $data['role'] : false; |
| | | $toTerm = (array_key_exists('to_term', $data)) ? (int)$data['to_term'] : false; |
| | | $taxonomy = (array_key_exists('taxonomy', $data) && in_array($data['taxonomy'], $this->inviteTypes[$role]['to_terms']??[])) ? $data['taxonomy'] : false; |
| | | |
| | | return [ |
| | | 'user' => (array_key_exists('user', $data)) ? (int)$data['user'] : false, |
| | | 'role' => $role, |
| | | 'to_term' => $toTerm, |
| | | 'taxonomy' => $taxonomy, |
| | | 'status' => array_key_exists('status', $data) && in_array($data['status'], ['all', 'pending', 'accepted', 'rejected', 'expired', 'revoked']) ? $data['status'] : 'all', |
| | | 'page' => array_key_exists('page', $data) ? (int)$data['page'] : 1, |
| | | ]; |
| | | } |
| | | /** |
| | | * @param object $request the request object |
| | | * |
| | | * @return WP_REST_Response |
| | | */ |
| | | public function getInvitations(object $request): WP_REST_Response |
| | | { |
| | | $args = $this->buildParams($request); |
| | | if ($args['user']) { |
| | | if (!$this->userCheck($args['user'])) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Looks like you are not who you say you are' |
| | | ]); |
| | | } |
| | | if (!$this->isVerifiedUser($args['user'])) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Sorry, you don\'t have permission to do this.', |
| | | ]); |
| | | } |
| | | return $this->getUserInvitations($args); |
| | | } elseif ($args['to_term']) { |
| | | if (!$this->checkTerm($args)) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Looks like this '.$args['taxonomy'].' does not exist' |
| | | ]); |
| | | } |
| | | return $this->getTermInvitations($args); |
| | | } |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Invalid request' |
| | | ]); |
| | | } |
| | | |
| | | public function getTermInvitations(array $args):WP_REST_Response |
| | | { |
| | | if (!$this->checkTerm($args)) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Invalid shop' |
| | | ]); |
| | | } |
| | | |
| | | if (!user_can($args['user'], 'manage_'.$args['taxonomy'].'_'.$args['to_term'])) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'You do not have permission to view invitations for this '.$args['taxonomy'] |
| | | ]); |
| | | } |
| | | |
| | | $key = $this->cache->generateKey($args); |
| | | |
| | | $cache = $this->cache->get($key); |
| | | if ($cache) { |
| | | return new WP_REST_Response($cache); |
| | | } |
| | | |
| | | $per_page = 20; |
| | | |
| | | $conditions = []; |
| | | $params = []; |
| | | |
| | | //Filter by term |
| | | $conditions[] = "to_{$args['taxonomy']} = %d"; |
| | | $params[] = $args['to_term']; |
| | | |
| | | if ($args['status'] !== 'all') { |
| | | $conditions[] = "status = %s"; |
| | | $params[] = $args['status']; |
| | | } |
| | | |
| | | $where = !empty($conditions) ? " WHERE " .implode(' AND ', $conditions) : ""; |
| | | |
| | | //Count total for pagination |
| | | $count_query = "SELECT COUNT(*) FROM {$this->tableNames[$args['role']]} {$where}"; |
| | | $total = $this->wpdb->get_var($this->wpdb->prepare($count_query, $params)); |
| | | |
| | | //Get paginated invitations |
| | | $offset = ($args['page'] - 1) * $per_page; |
| | | $query = $count_query." ORDER BY created_at DESC LIMIT %d OFFSET %d"; |
| | | |
| | | //Add pagination |
| | | $pagination = array_merge($params, [$per_page, $offset]); |
| | | $invitations = $this->wpdb->get_results($this->wpdb->prepare($query, $pagination)); |
| | | |
| | | $formatted = []; |
| | | foreach ($invitations as $invitation) { |
| | | $formatted[] = $this->formatInvitation($invitation); |
| | | } |
| | | |
| | | $return = [ |
| | | 'invitations' => $formatted, |
| | | 'total' => (int)$total, |
| | | 'pages' => ceil($total /$per_page), |
| | | 'page' => $args['page'], |
| | | 'per_page' => $per_page |
| | | ]; |
| | | |
| | | $this->cache->set($key, $return); |
| | | return new WP_REST_Response($return); |
| | | } |
| | | |
| | | protected function buildInvitationArgs(object $request):array |
| | | { |
| | | $data = $request->get_params(); |
| | | |
| | | $user = (array_key_exists('user', $data) && $this->userCheck($data['user'])) ? (int) $data['user'] : false; |
| | | if (!$user) { |
| | | return []; |
| | | } |
| | | $role = jvbUserRole($user); |
| | | $args = [ |
| | | 'user' => $user, |
| | | 'role' => $role, |
| | | 'action' => (array_key_exists('action', $data) && in_array($data['action'], ['refresh', 'revoke', 'create'])) ? $data['action'] : false, |
| | | 'inviteID' => (array_key_exists('refresh', $data)) ? (int) $data['refresh'] : false, |
| | | ]; |
| | | |
| | | $allowed = $this->inviteTypes[$role]; |
| | | if (count($allowed) > 1) { |
| | | $inviteAs = (array_key_exists('type', $data) && in_array($data['type'], $allowed)) ? $data['type'] : false; |
| | | } else { |
| | | $invitedAs = $allowed[0]; |
| | | } |
| | | |
| | | if (array_key_exists('invites', $data)) { |
| | | $invites = []; |
| | | foreach ($data['invites'] as $invite) { |
| | | $temp = [ |
| | | 'invited_id' => (array_key_exists('invited_id', $invite) && $this->userCheck($invite['invited_id'])) ? $invite['invited_id'] : false, |
| | | 'to_term' => (array_key_exists('to_term', $invite)) ? (int) $invite['to_term'] : false, |
| | | 'taxonomy' => (array_key_exists('taxonomy', $invite) && in_array($invite['taxonomy'], $this->inviteTypes[$role]['to_terms']??[])) ? $invite['taxonomy'] : false, |
| | | 'invited_name' => (array_key_exists('name', $invite) && is_string($invite['name'])) ? sanitize_text_field($invite['name']) : false, |
| | | 'invited_email' => (array_key_exists('email', $invite) && is_email($invite['email'])) ? sanitize_email($invite['email']) : false, |
| | | ]; |
| | | if ($temp['invited_id'] || ($temp['invited_email'] && $temp['invited_name'])) { |
| | | $invites[$invitedAs][] = $data; |
| | | } |
| | | } |
| | | $args['invites'] = $invites; |
| | | } |
| | | if (!$invitedAs && !empty($args['invites'])) { |
| | | unset($args['invites']); |
| | | } |
| | | |
| | | return $args; |
| | | } |
| | | /** |
| | | * @param object $request The Request Object |
| | | * |
| | | * @return WP_REST_Response |
| | | */ |
| | | public function createInvitationRequest(object $request):WP_REST_Response |
| | | { |
| | | $args = $this->buildInvitationArgs($request); |
| | | |
| | | $error = ''; |
| | | if (!$args['user']) { |
| | | $error = 'User ID doesn\'t match up.... are you a bot?'; |
| | | } elseif (Features::forMembership()->has('member_verified') && !user_can($args['user'], 'skip_moderation')) { |
| | | $error = 'Only verified users can send invitations.'; |
| | | } elseif (!$args['role']) { |
| | | $error = 'It doesn\'t look like you can invite users.'; |
| | | } |
| | | if ($error !== '') { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => $error |
| | | ]); |
| | | } |
| | | |
| | | switch ($args['action']) { |
| | | case 'revoke': |
| | | return $this->revokeInvite($args); |
| | | case 'refresh': |
| | | return $this->resendInvite($args); |
| | | } |
| | | |
| | | //Inviting to content taxonomy (ie: shop) |
| | | $artist = jvbContentFromUser($args['user']); |
| | | foreach ($args['invites'] as $index => $invite) { |
| | | if ($invite['to_term'] && $invite['taxonomy']) { |
| | | if (!$artist[$invite['taxonomy']] || $artist[$invite['taxonomy']['id'] !== $invite['term_id']]) { |
| | | $args['invites'][$index]['to_term'] = false; |
| | | $args['invites'][$index]['taxonomy'] = false; |
| | | } |
| | | } |
| | | } |
| | | |
| | | if (!empty($args['invites']??[])) { |
| | | JVB()->queue()->queueOperation( |
| | | 'invitation_create', |
| | | $args['user'], |
| | | [ |
| | | 'invitations' => $args['invites'], |
| | | ], |
| | | [ |
| | | 'count' => count($args['invites']), |
| | | 'priority' => 'high', |
| | | 'chunk_size' => 20, |
| | | 'chunk_key' => 'invitations' |
| | | ] |
| | | ); |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => 'Processing ' . count($args['invites']) . ' invitations', |
| | | ]); |
| | | } |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'No invitations sent.' |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Revoke an invitation |
| | | * |
| | | * @params array $args |
| | | * @return array Response with success or error message |
| | | */ |
| | | public function revokeInvite(array $args): array |
| | | { |
| | | $invitation = $this->getInvitationByUser($args); |
| | | |
| | | if (!$invitation || is_wp_error($invitation)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => 'Invitation not found' |
| | | ]; |
| | | } |
| | | |
| | | // Check if invitation can be revoked (only pending invitations) |
| | | if ($invitation['status'] !== 'pending' && $invitation['status'] !== 'expired') { |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => 'Only pending or expired invitations can be revoked' |
| | | ]; |
| | | } |
| | | |
| | | // Check if the user is one of the inviters |
| | | $inviters = json_decode($invitation['inviters'], true); |
| | | $user_is_inviter = false; |
| | | $updated_inviters = []; |
| | | |
| | | foreach ($inviters as $inviter) { |
| | | if (intval($inviter['user_id']) === $args['user']) { |
| | | $user_is_inviter = true; |
| | | } else { |
| | | // Keep other inviters |
| | | $updated_inviters[] = $inviter; |
| | | } |
| | | } |
| | | |
| | | if (!$user_is_inviter) { |
| | | return [ |
| | | 'success' => false, |
| | | 'return' => 'You are not authorized to revoke this invitation' |
| | | ]; |
| | | } |
| | | |
| | | // If there are still other inviters, just update the inviters list |
| | | if (!empty($updated_inviters)) { |
| | | $this->wpdb->update( |
| | | $this->tableNames[$args['role']], |
| | | [ |
| | | 'inviters' => json_encode($updated_inviters), |
| | | 'updated_at' => current_time('mysql') |
| | | ], |
| | | ['id' => $invitation['id']] |
| | | ); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => 'You have been removed from the inviters list but the invitation is still active with other inviters', |
| | | ]; |
| | | } |
| | | |
| | | // If no inviters left, mark the invitation as revoked |
| | | $this->wpdb->update( |
| | | $this->tableNames[$args['role']], |
| | | [ |
| | | 'status' => 'revoked', |
| | | 'updated_at' => current_time('mysql') |
| | | ], |
| | | ['id' => $invitation['id'] ] |
| | | ); |
| | | |
| | | $this->sendRevocationEmail($invitation['email'], $invitation['name']); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => 'Invitation has been successfully revoked' |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Resend an expired invitation |
| | | * |
| | | * @param array $args Args, as defined in buildInvitationArgs()) |
| | | * @return WP_REST_Response Response with success or error message |
| | | */ |
| | | public function resendInvite(array $args): WP_REST_Response |
| | | { |
| | | $invitation_id = isset($args['inviteID']) ? intval($args['inviteID']) : 0; |
| | | $user_id = isset($args['user']) ? intval($args['user']) : 0; |
| | | |
| | | if (!$invitation_id || !$user_id) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Missing invitation ID or user ID' |
| | | ]); |
| | | } |
| | | |
| | | // Get the invitation |
| | | $invitation = $this->wpdb->get_row($this->wpdb->prepare( |
| | | "SELECT * FROM {$this->tableNames[$args['role']]} WHERE id = %d", |
| | | $invitation_id |
| | | ), ARRAY_A); |
| | | |
| | | if (!$invitation) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Invitation not found' |
| | | ]); |
| | | } |
| | | |
| | | // Check if the invitation is expired or pending |
| | | if (!in_array($invitation['status'], ['expired', 'pending'])) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Only expired or pending invitations can be resent' |
| | | ]); |
| | | } |
| | | |
| | | // Check if the user is one of the inviters |
| | | $inviters = json_decode($invitation['inviters'], true); |
| | | $user_is_inviter = false; |
| | | |
| | | foreach ($inviters as &$inviter) { |
| | | if (intval($inviter['user_id']) === $user_id) { |
| | | $user_is_inviter = true; |
| | | // Update the invited_at timestamp for this inviter |
| | | $inviter['invited_at'] = current_time('mysql'); |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if (!$user_is_inviter) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'You are not authorized to resend this invitation' |
| | | ]); |
| | | } |
| | | |
| | | // Generate a new token |
| | | $token = wp_generate_password(32, false); |
| | | |
| | | // Set new expiration date |
| | | $expires_at = date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days")); |
| | | |
| | | // Update the invitation |
| | | $this->wpdb->update( |
| | | $this->tableNames[$args['role']], |
| | | [ |
| | | 'invitation_token' => $token, |
| | | 'status' => 'pending', |
| | | 'expires_at' => $expires_at, |
| | | 'inviters' => json_encode($inviters), |
| | | 'updated_at' => current_time('mysql') |
| | | ], |
| | | ['id' => $invitation_id] |
| | | ); |
| | | |
| | | // Send the invitation email again |
| | | $name = $invitation['name']; |
| | | $email = $invitation['email']; |
| | | $role = $invitation['role']; |
| | | $terms = $this->getInvitationTerms($invitation, $role); |
| | | |
| | | |
| | | $result = $this->sendInvitationEmail($name, $email, $token, $user_id, $terms, $role); |
| | | |
| | | if (!$result) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Failed to send invitation email' |
| | | ]); |
| | | } |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => true, |
| | | 'message' => 'Invitation has been successfully resent', |
| | | 'expires_at' => $expires_at |
| | | ]); |
| | | } |
| | | |
| | | protected function getInvitationTerms(object|array $invitation, string $role) { |
| | | if (is_object($invitation)) { |
| | | $invitation = json_decode(json_encode($invitation), true); |
| | | } |
| | | $terms = []; |
| | | foreach ($this->inviteTypes[$role]['to_terms'] as $taxonomy) { |
| | | $terms[$taxonomy] = $invitation['to_'.$taxonomy]; |
| | | } |
| | | return $terms; |
| | | // Cache connections |
| | | $this->cache |
| | | ->connect('user') |
| | | ->connect('taxonomy'); |
| | | } |
| | | |
| | | /** |
| | | * Create or update an invitation |
| | | * @param string $name Name of person being invited |
| | | * @param string $email Email of person being invited |
| | | * @param int $inviter_id User ID of the person inviting |
| | | * @param string|false $role |
| | | * @param int|false $termID Optional shop ID |
| | | * @param string|false $taxonomy Optional taxonomy |
| | | * @param bool $send_email whether to send email right away |
| | | * @return WP_Error|array |
| | | * |
| | | */ |
| | | public function createInvitation( |
| | | string $name, |
| | | string $email, |
| | | int $inviter_id, |
| | | string|false $role = false, |
| | | int|false $termID = false, |
| | | string|false $taxonomy = false, |
| | | bool $send_email = true |
| | | ):WP_Error|array { |
| | | error_log('Creating Invitation with data: '.print_r([ |
| | | 'name' => $name, |
| | | 'email' => $email, |
| | | 'inviter ID'=> $inviter_id, |
| | | 'termID' => $termID, |
| | | 'taxonomy' => $taxonomy, |
| | | 'role' => $role |
| | | ], true)); |
| | | // Sanitize and validate email |
| | | $email = sanitize_email($email); |
| | | if (!is_email($email)) { |
| | | error_log('Invalid email'); |
| | | return new WP_Error('invalid_email', 'Invalid email address'); |
| | | } |
| | | public function registerRoutes(): void |
| | | { |
| | | Route::for('invitations') |
| | | ->get([$this, 'getInvitations']) |
| | | ->args([ |
| | | 'user' => 'int|required', |
| | | 'to_term' => 'int', |
| | | 'taxonomy' => 'string', |
| | | 'status' => 'string|enum:all,pending,accepted,rejected,expired,revoked|default:all', |
| | | 'page' => 'int|default:1|min:1' |
| | | ]) |
| | | ->auth('user') |
| | | ->rateLimit(20) |
| | | |
| | | // Check if inviter is verified |
| | | if (Features::forMembership()->has('member_verified') && !$this->isVerifiedUser($inviter_id)) { |
| | | error_log('Unverified Artist'); |
| | | return new WP_Error('unauthorized', 'Only verified artists can send invitations'); |
| | | } |
| | | ->post([$this, 'createInvitationRequest']) |
| | | ->args([ |
| | | 'user' => 'int|required', |
| | | 'action' => 'string|enum:create,revoke,refresh|default:create', |
| | | 'invites' => 'array', |
| | | 'invitation_id' => 'int' |
| | | ]) |
| | | ->auth('verified') |
| | | ->rateLimit(10, 300); |
| | | } |
| | | |
| | | if ($termID) { |
| | | // Check if shop exists if specified |
| | | if ($this->checkTerm(['term_id' => $termID, 'taxonomy' => $taxonomy])) { |
| | | error_log('Invalid Taxonomy'); |
| | | return new WP_Error('invalid_term', 'The specified term does not exist'); |
| | | } |
| | | } |
| | | /** |
| | | * Get invitations for a user or term |
| | | */ |
| | | public function getInvitations(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $userID = $request->get_param('user'); |
| | | $termID = $request->get_param('to_term'); |
| | | $taxonomy = $request->get_param('taxonomy'); |
| | | |
| | | if (!$role || !array_key_exists($role, $this->inviteTypes)) { |
| | | return new WP_Error('invalid_role', 'No role was set'); |
| | | } |
| | | |
| | | // Check if user already exists |
| | | $invite = !email_exists($email); |
| | | |
| | | // Get existing invitation if any |
| | | $existing = $this->wpdb->get_row($this->wpdb->prepare( |
| | | "SELECT * FROM {$this->tableNames[$role]} WHERE email = %s", |
| | | $email |
| | | ), ARRAY_A); |
| | | |
| | | // Generate token |
| | | $token = wp_generate_password(32, false); |
| | | |
| | | // Set expiration date |
| | | $expires_at = date('Y-m-d H:i:s', strtotime("+{$this->expiryDays} days")); |
| | | |
| | | if ($existing) { |
| | | // Update existing invitation |
| | | $inviters = json_decode($existing['inviters'], true); |
| | | |
| | | // Check if this inviter already invited |
| | | $inviter_exists = false; |
| | | foreach ($inviters as $inviter) { |
| | | if ($inviter['user_id'] == $inviter_id) { |
| | | $inviter_exists = true; |
| | | // Update the invited_at timestamp |
| | | $inviter['invited_at'] = current_time('mysql'); |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if (!$inviter_exists) { |
| | | // Add new inviter |
| | | $inviters[] = [ |
| | | 'user_id' => $inviter_id, |
| | | 'invited_at' => current_time('mysql') |
| | | ]; |
| | | } |
| | | |
| | | // Prepare update data |
| | | $update_data = [ |
| | | 'inviters' => json_encode($inviters), |
| | | 'status' => 'pending', |
| | | 'expires_at' => $expires_at, |
| | | 'updated_at' => current_time('mysql'), |
| | | ]; |
| | | // Set shop_id if provided and not already set |
| | | $check = 'to_'.$taxonomy; |
| | | if ($termID && $existing[$check] !== $termID) { |
| | | $update_data[$check] = $termID; |
| | | } |
| | | |
| | | // If invitation was expired, generate new token |
| | | if ($existing['status'] === 'expired') { |
| | | $update_data['invitation_token'] = $token; |
| | | } else { |
| | | $token = $existing['invitation_token']; |
| | | } |
| | | |
| | | $this->wpdb->update( |
| | | $this->tableNames[$role], |
| | | $update_data, |
| | | ['id' => $existing['id']] |
| | | ); |
| | | |
| | | $invitation_id = $existing['id']; |
| | | } else { |
| | | // Create new invitation |
| | | $inviters = [[ |
| | | 'user_id' => $inviter_id, |
| | | 'invited_at' => current_time('mysql') |
| | | ]]; |
| | | |
| | | $insert_data = [ |
| | | 'name' => sanitize_text_field($name), |
| | | 'email' => $email, |
| | | 'invitation_token' => $token, |
| | | 'status' => 'pending', |
| | | 'inviters' => json_encode($inviters), |
| | | 'expires_at' => $expires_at, |
| | | 'created_at' => current_time('mysql') |
| | | ]; |
| | | // Add shop if provided |
| | | if ($termID) { |
| | | $insert_data['to_'.$taxonomy] = $termID; |
| | | } |
| | | |
| | | $this->wpdb->insert( |
| | | $this->tableNames[$role], |
| | | $insert_data |
| | | ); |
| | | |
| | | $invitation_id = $this->wpdb->insert_id; |
| | | } |
| | | |
| | | error_log('On to invitation email send:'); |
| | | // Send invitation email |
| | | if ($invite && $send_email) { |
| | | $this->sendInvitationEmail($name, $email, $token, $inviter_id, [$taxonomy => $termID], $role); |
| | | } |
| | | |
| | | return [ |
| | | 'id' => $invitation_id, |
| | | 'email' => $email, |
| | | 'token' => $token, |
| | | 'expires_at' => $expires_at |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Validate an invitation token |
| | | * @param string $token the generated token |
| | | * @param string $email the email of the invited person |
| | | * @param string $role the role |
| | | * @return object $invitation or error |
| | | */ |
| | | public function validateInvitation(string $token, string $email, string $role):object |
| | | { |
| | | if (!array_key_exists($role, $this->inviteTypes)) { |
| | | return new WP_Error('invalid_role', 'Invalid role type'); |
| | | // Validate user |
| | | if (get_current_user_id() !== $userID) { |
| | | return $this->unauthorized('Invalid user'); |
| | | } |
| | | $table = $this->wpdb->prefix . $this->tableNames[$role]; |
| | | |
| | | // Get invitation by token and email |
| | | $invitation = $this->wpdb->get_row($this->wpdb->prepare( |
| | | "SELECT * FROM $table |
| | | WHERE invitation_token = %s |
| | | AND email = %s |
| | | AND status = 'pending'", |
| | | $token, |
| | | $email |
| | | )); |
| | | $args = [ |
| | | 'user' => $userID, |
| | | 'to_term' => $termID, |
| | | 'taxonomy' => $taxonomy ? jvbNoBase($taxonomy) : null, |
| | | 'status' => $request->get_param('status'), |
| | | 'page' => $request->get_param('page') |
| | | ]; |
| | | |
| | | if (!$invitation) { |
| | | return new WP_Error('invalid_invitation', 'Invalid invitation token or email'); |
| | | } |
| | | // Check cache |
| | | $key = $this->cache->generateKey($args); |
| | | if ($cached = $this->cache->get($key)) { |
| | | return $this->success($cached); |
| | | } |
| | | |
| | | // Check if expired |
| | | if (strtotime($invitation->expires_at) < time()) { |
| | | return new WP_Error('expired_invitation', 'This invitation has expired'); |
| | | } |
| | | // Get appropriate invitations |
| | | $result = ($args['to_term'] && $args['taxonomy']) |
| | | ? $this->getTermInvitations($args) |
| | | : $this->getUserInvitations($args); |
| | | |
| | | return $invitation; |
| | | } |
| | | // Cache result |
| | | $this->cache->set($key, $result); |
| | | |
| | | /** |
| | | * Send invitation email to the new artist |
| | | * @param string $name The invited person's name |
| | | * @param string $email The invited person's email |
| | | * @param string $token The randomly generated password |
| | | * @param int $inviter_id The User ID of the one inviting |
| | | * @param int|null $shopID The optional shop ID to be invited to |
| | | * @return bool Whether or not the invitation was sent successfully |
| | | */ |
| | | protected function sendInvitationEmail(string $name, string $email, string $token, int $inviter_id, array $terms, string|null $role = null):bool |
| | | { |
| | | $inviter = get_userdata($inviter_id); |
| | | $inviter_name = jvbGetUsername($inviter_id); |
| | | $inviter_name = $inviter_name ?: $inviter->display_name; |
| | | return $this->success($result); |
| | | } |
| | | |
| | | $siteName = get_bloginfo('name'); |
| | | /** |
| | | * Get invitations for a specific term |
| | | */ |
| | | protected function getTermInvitations(array $args): array |
| | | { |
| | | // Check permission |
| | | if (!JVB()->roles()->isManager($args['user'], $args['to_term'])) { |
| | | return $this->forbidden('You cannot view invitations for this ' . $args['taxonomy'])->get_data(); |
| | | } |
| | | |
| | | $subject = apply_filters('jvbInvitationSubject', |
| | | sprintf( |
| | | "%s invited you to join %s!", |
| | | $inviter_name, |
| | | $siteName |
| | | ), |
| | | $inviter_name |
| | | $perPage = 20; |
| | | $offset = ($args['page'] - 1) * $perPage; |
| | | |
| | | // Build query |
| | | $where = ['to_' . $args['taxonomy'] => $args['to_term']]; |
| | | if ($args['status'] !== 'all') { |
| | | $where['status'] = $args['status']; |
| | | } |
| | | |
| | | // Fluent CustomTable usage |
| | | $total = $this->table |
| | | ->where($where) |
| | | ->countResults(); |
| | | |
| | | $invitations = $this->table |
| | | ->where($where) |
| | | ->orderBy('created_at') |
| | | ->limit($perPage, $offset) |
| | | ->getResults(); |
| | | |
| | | return [ |
| | | 'invitations' => array_map([$this, 'formatInvitation'], $invitations), |
| | | 'total' => $total, |
| | | 'pages' => ceil($total / $perPage), |
| | | 'page' => $args['page'], |
| | | 'per_page' => $perPage |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Get invitations sent by a user |
| | | */ |
| | | protected function getUserInvitations(array $args): array |
| | | { |
| | | $perPage = 20; |
| | | $offset = ($args['page'] - 1) * $perPage; |
| | | |
| | | // Use raw query for JSON search |
| | | $query = "SELECT * FROM {$this->table->getFullTableName()} |
| | | WHERE JSON_SEARCH(inviters, 'one', %s, NULL, '$[*].user_id') IS NOT NULL"; |
| | | $params = [$args['user']]; |
| | | |
| | | if ($args['status'] !== 'all') { |
| | | $query .= " AND status = %s"; |
| | | $params[] = $args['status']; |
| | | } |
| | | |
| | | $query .= " ORDER BY created_at DESC LIMIT %d OFFSET %d"; |
| | | $params = array_merge($params, [$perPage, $offset]); |
| | | |
| | | $invitations = $this->table->queryResults($query, $params); |
| | | |
| | | // Get count |
| | | $countQuery = str_replace('SELECT *', 'SELECT COUNT(*)', |
| | | substr($query, 0, strrpos($query, 'ORDER BY'))); |
| | | $countParams = array_slice($params, 0, -2); |
| | | $total = (int) $this->table->queryVar($countQuery, $countParams); |
| | | |
| | | return [ |
| | | 'invitations' => array_map([$this, 'formatInvitation'], $invitations), |
| | | 'total' => $total, |
| | | 'pages' => ceil($total / $perPage), |
| | | 'page' => $args['page'], |
| | | 'per_page' => $perPage |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Create invitation request - queues invitations for processing |
| | | */ |
| | | public function createInvitationRequest(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $userID = $request->get_param('user'); |
| | | $action = $request->get_param('action'); |
| | | |
| | | // Validate user |
| | | if (get_current_user_id() !== $userID) { |
| | | return $this->unauthorized('Invalid user'); |
| | | } |
| | | |
| | | // Handle actions |
| | | return match($action) { |
| | | 'revoke' => $this->revokeInvite($userID, $request->get_params()), |
| | | 'refresh' => $this->resendInvite($userID, $request->get_params()), |
| | | default => $this->queueInvitations($userID, $request->get_param('invites')) |
| | | }; |
| | | } |
| | | |
| | | protected function queueInvitations(int $userID, array $invites): WP_REST_Response |
| | | { |
| | | if (empty($invites)) { |
| | | return $this->error('No invitations provided'); |
| | | } |
| | | |
| | | // Validate invitations |
| | | $validated = $this->validateInvitations($userID, $invites); |
| | | |
| | | if (empty($validated)) { |
| | | return $this->error('No valid invitations to send'); |
| | | } |
| | | |
| | | // Queue using fluent interface |
| | | $op = JVB()->queue()->add( |
| | | 'invitation_create', |
| | | $userID, |
| | | ['invitations' => $validated], |
| | | [ |
| | | 'priority' => 'high', |
| | | 'chunk_key' => 'invitations', |
| | | 'chunk_size' => 20 |
| | | ] |
| | | ); |
| | | return $this->queued($op['operation_id']); |
| | | } |
| | | |
| | | /** |
| | | * Validate and sanitize invitation data |
| | | */ |
| | | protected function validateInvitations(int $userID, array $invites): array |
| | | { |
| | | return JVB()->invitations()->validateInvitations($userID, $invites); |
| | | } |
| | | |
| | | |
| | | |
| | | |
| | | /** |
| | | * Revoke an invitation |
| | | */ |
| | | protected function revokeInvite(int $userID, array $data): WP_REST_Response |
| | | { |
| | | $invitationID = (int) ($data['invitation_id'] ?? 0); |
| | | |
| | | if (!$invitationID) { |
| | | return $this->error('Invitation ID required'); |
| | | } |
| | | |
| | | $op = JVB()->queue()->add( |
| | | 'invitation_revoke', |
| | | $userID, |
| | | ['invitation_id' => $invitationID], |
| | | ['priority' => 'high'] |
| | | ); |
| | | |
| | | $signup_url = add_query_arg([ |
| | | 'invite' => $token, |
| | | 'email' => urlencode($email), |
| | | 'name' => $name, |
| | | 'role' => $role |
| | | ], wp_registration_url()); |
| | | return $this->queued($op['operation_id']); |
| | | } |
| | | |
| | | /** |
| | | * Resend an invitation |
| | | */ |
| | | protected function resendInvite(int $userID, array $data): WP_REST_Response |
| | | { |
| | | $invitationID = (int) ($data['invitation_id'] ?? 0); |
| | | |
| | | if (!$invitationID) { |
| | | return $this->error('Invitation ID required'); |
| | | } |
| | | |
| | | $op = JVB()->queue()->add( |
| | | 'invitation_resend', |
| | | $userID, |
| | | ['invitation_id' => $invitationID], |
| | | ['priority' => 'high'] |
| | | ); |
| | | |
| | | if (is_wp_error($op)) { |
| | | return $this->error($op->get_error_message()); |
| | | } |
| | | return $this->queued($op['operation_id']); |
| | | } |
| | | |
| | | |
| | | // Get shop name if applicable |
| | | $toContentTax = []; |
| | | if (!empty ($terms)) { |
| | | foreach ($terms as $taxonomy => $termID) { |
| | | |
| | | /** |
| | | * Format invitation for API response |
| | | */ |
| | | protected function formatInvitation(object $invitation): array |
| | | { |
| | | $inviters = json_decode($invitation->inviters ?? '[]', true) ?: []; |
| | | |
| | | $formatted = [ |
| | | 'id' => (int) $invitation->id, |
| | | 'name' => $invitation->name, |
| | | 'email' => $invitation->email, |
| | | 'invited_role' => $invitation->invited_role, |
| | | 'status' => $invitation->status, |
| | | 'expires_at' => $invitation->expires_at, |
| | | 'accepted_at' => $invitation->accepted_at ?? null, |
| | | 'created_at' => $invitation->created_at, |
| | | 'inviters' => array_map(fn($inviter) => [ |
| | | 'id' => (int) $inviter['user_id'], |
| | | 'name' => jvbGetUsername($inviter['user_id']), |
| | | 'invited_at' => $inviter['invited_at'] |
| | | ], $inviters) |
| | | ]; |
| | | |
| | | // Add term information if present |
| | | foreach (JVB()->roles()->getInvitableTaxonomies() as $taxonomy) { |
| | | $column = 'to_' . $taxonomy; |
| | | if (isset($invitation->$column) && $invitation->$column) { |
| | | $termID = (int) $invitation->$column; |
| | | $term = get_term($termID, BASE . $taxonomy); |
| | | |
| | | if ($term && !is_wp_error($term)) { |
| | | $toContentTax[] = sprintf( |
| | | "<p>%s has also invited you to join %s. You'll be automatically added to this %s when you register.</p>", |
| | | $inviter_name, |
| | | html_entity_decode($term->name), |
| | | $taxonomy |
| | | ); |
| | | $formatted['term'] = [ |
| | | 'id' => $termID, |
| | | 'name' => $term->name, |
| | | 'taxonomy' => $taxonomy |
| | | ]; |
| | | break; // Only show first term |
| | | } |
| | | } |
| | | } |
| | | $toContentTax = implode(' ', $toContentTax); |
| | | |
| | | $button = JVB()->email()->button($signup_url, 'Join the Scene!'); |
| | | $link = JVB()->email()->link($signup_url); |
| | | $signature = JVB()->email()->signature(); |
| | | |
| | | $message = sprintf( |
| | | '<p>Hi %s!</p> |
| | | <p>%s has invited you to join them on %s.</p> |
| | | |
| | | <h2>Interested?</h2> |
| | | <p>Join in by clicking the button below:</p> |
| | | %s |
| | | <p>Or by copying and pasting the link below into your browser:</p> |
| | | %s |
| | | <div class="divider"></div> |
| | | %s |
| | | <p>This invitation expires in %d days.</p> |
| | | <p>Ink on, %s</p> |
| | | %s |
| | | ', |
| | | $name, |
| | | $inviter_name, |
| | | $siteName, |
| | | $button, |
| | | $link, |
| | | $name, |
| | | $toContentTax, |
| | | $this->expiryDays, |
| | | $signature |
| | | ); |
| | | $message = apply_filters('jvbInvitationMessage', |
| | | $message, |
| | | $name, |
| | | $inviter_name, |
| | | $role, |
| | | $termID, |
| | | $taxonomy, |
| | | $toContentTax, |
| | | $this->expiryDays, |
| | | $button, |
| | | $link, |
| | | $signature, |
| | | ); |
| | | |
| | | |
| | | $success = JVB()->email()->sendEmail($email, $subject, $message); |
| | | |
| | | |
| | | if (!$success) { |
| | | // Log the invitation |
| | | JVB()->error()->log( |
| | | 'invitation_email', |
| | | 'Invitation not sent', |
| | | [ |
| | | 'email' => $email, |
| | | 'inviter_id' => $inviter_id, |
| | | 'token' => $token |
| | | ], |
| | | 'info' |
| | | ); |
| | | } |
| | | |
| | | return $success; |
| | | } |
| | | |
| | | /** |
| | | * Send revocation email notification |
| | | * @param string $email the invited person's email |
| | | * @param string $name the invited person's name |
| | | * @return bool Whether or not the email was sent |
| | | */ |
| | | protected function sendRevocationEmail(string $email, string $name):bool |
| | | { |
| | | $siteName = get_bloginfo('name'); |
| | | $subject = apply_filters( |
| | | 'jvbInvitationRevokedSubject', |
| | | sprintf( |
| | | '[%s] Your invitation has been revoked', |
| | | $siteName |
| | | ) |
| | | ); |
| | | $content = apply_filters( |
| | | 'jvbInvitationRevokedMessage', |
| | | sprintf( |
| | | '<p>Hey %s,</p> |
| | | <p>This is to let you know that your invitation to join %s has been revoked.</p> |
| | | <p>If you believe this was done in error, please contact the person who invited you, the site admin, or try registering yourself.</p>', |
| | | $name, |
| | | $siteName |
| | | ), |
| | | $name |
| | | ); |
| | | |
| | | $success = JVB()->email()->sendEmail($email, $subject, $content, 'INVITATION REVOKED'); |
| | | if (!$success) { |
| | | JVB()->error()->log( |
| | | 'invitation_revoke_email', |
| | | 'Invitation not sent', |
| | | [ |
| | | 'email' => $email, |
| | | 'name' => $name, |
| | | ], |
| | | 'info' |
| | | ); |
| | | } |
| | | return $success; |
| | | } |
| | | |
| | | /** |
| | | * Verify an invitation token |
| | | * @param string $token The randomly generated token |
| | | * @param string $email The invited person's email |
| | | * @return bool|object False on failure. Invitation object if success |
| | | */ |
| | | public function verifyInvitation(string $token, string $email, string $role):bool|object |
| | | { |
| | | $invitation = $this->wpdb->get_row($this->wpdb->prepare( |
| | | "SELECT * FROM {$this->tableNames[$role]} |
| | | WHERE invitation_token = %s AND email = %s AND status = 'pending' AND expires_at > NOW()", |
| | | $token, |
| | | $email |
| | | )); |
| | | |
| | | if (!$invitation) { |
| | | return false; |
| | | } |
| | | |
| | | return $invitation; |
| | | } |
| | | |
| | | /** |
| | | * Mark an invitation as accepted |
| | | * @param string $token The randomly generated token |
| | | * @param string $email The invited person's email |
| | | * @param int $user_id The invited person's user ID |
| | | * @return bool whether or not it was successfully accepted |
| | | */ |
| | | public function acceptInvitation(string $token, string $email, int $user_id):bool |
| | | { |
| | | $role = jvbUserRole($user_id); |
| | | $invitation = $this->verifyInvitation($token, $email, $role); |
| | | |
| | | if (!$invitation) { |
| | | return false; |
| | | } |
| | | |
| | | // Update invitation status |
| | | $this->wpdb->update( |
| | | $this->tableNames[$role], |
| | | [ |
| | | 'status' => 'accepted', |
| | | 'new_user_id' => $user_id, |
| | | 'accepted_at' => current_time('mysql'), |
| | | 'updated_at' => current_time('mysql') |
| | | ], |
| | | ['id' => $invitation->id] |
| | | ); |
| | | |
| | | // Set user role to artist with can_publish=false (needs verification) |
| | | $user = get_userdata($user_id); |
| | | // Set the user's verification status |
| | | $user->add_cap('skip_moderation', true); |
| | | |
| | | // If there's a shop to add the artist to, do that now |
| | | if (!empty($invitation->to_shop)) { |
| | | JVB()->routes('shopInvite')->addArtistToShop($user_id, $invitation->to_shop); |
| | | } |
| | | |
| | | // Notify inviters |
| | | $this->notifyInvitersOfAcceptance($invitation, $user_id); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Notify all inviters that the invitation was accepted |
| | | * @param object $invitation The invitation object |
| | | * @param int $user_id the newly added user id |
| | | * @return void |
| | | */ |
| | | protected function notifyInvitersOfAcceptance(object $invitation, int $user_id):void |
| | | { |
| | | $inviters = json_decode($invitation->inviters, true); |
| | | $user_data = get_userdata($user_id); |
| | | |
| | | foreach ($inviters as $inviter) { |
| | | JVB()->notification()->addNotification( |
| | | $inviter['user_id'], |
| | | 'artist_joined', |
| | | [ |
| | | 'invited_email' => $invitation->email, |
| | | 'user_id' => $user_id, |
| | | 'display_name' => $user_data->display_name |
| | | ] |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Check if a registered user has a pending invitation. Accept invitation if so |
| | | * @param int $user_id The user ID to check |
| | | * @return void |
| | | */ |
| | | public function checkInvitation(int $user_id):void |
| | | { |
| | | $user = get_userdata($user_id); |
| | | |
| | | if (!$user) { |
| | | return; |
| | | } |
| | | |
| | | // Check if there's a token and email in the request |
| | | $token = isset($_GET['invite']) ? sanitize_text_field($_GET['invite']) : ''; |
| | | $email = isset($_GET['email']) ? sanitize_email($_GET['email']) : ''; |
| | | |
| | | if ($token && $email && $email === $user->user_email) { |
| | | $this->acceptInvitation($token, $email, $user_id); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Clean up expired invitations |
| | | * @return void |
| | | */ |
| | | public function cleanupExpiredInvitations():void |
| | | { |
| | | global $wpdb; |
| | | |
| | | $wpdb->query($wpdb->prepare( |
| | | "UPDATE {$this->tableName} |
| | | SET status = 'expired', updated_at = %s |
| | | WHERE status = 'pending' AND expires_at < NOW()", |
| | | current_time('mysql') |
| | | )); |
| | | } |
| | | |
| | | /** |
| | | * Get invitations sent by a specific user |
| | | * @param array $args built by buildParams() |
| | | * @return WP_REST_Response |
| | | */ |
| | | public function getUserInvitations(array $args):WP_REST_Response |
| | | { |
| | | if (!$this->checkUser($args['user'])) { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Invalid user' |
| | | ]); |
| | | } |
| | | |
| | | $key = $this->cache->generateKey($args); |
| | | $cache = $this->cache->get($key); |
| | | if ($cache) { |
| | | return new WP_REST_Response($cache); |
| | | } |
| | | |
| | | $per_page = 20; |
| | | |
| | | // Build query conditions |
| | | $conditions = []; |
| | | $params = []; |
| | | |
| | | $conditions[] = "inviters LIKE %s"; |
| | | $params[] = '%"'.$args['user'].'"%'; |
| | | |
| | | // Filter by status |
| | | if ($args['status'] !== 'all') { |
| | | $conditions[] = "status = %s"; |
| | | $params[] = $args['status']; |
| | | } |
| | | |
| | | $where = !empty($conditions) ? "WHERE " . implode(' AND ', $conditions) : ""; |
| | | |
| | | // Count total invitations for pagination |
| | | $count_query = "SELECT COUNT(*) FROM {$this->tableNames[$args['role']]} {$where}"; |
| | | $total = $this->wpdb->get_var($this->wpdb->prepare($count_query, $params)); |
| | | |
| | | // Get paginated invitations |
| | | $offset = ($args['page'] - 1) * $per_page; |
| | | $query = "SELECT * FROM {$this->tableNames[$args['role']]} {$where} ORDER BY created_at DESC LIMIT %d OFFSET %d"; |
| | | |
| | | // Add pagination parameters |
| | | $pagination_params = array_merge($params, [$per_page, $offset]); |
| | | $invitations = $this->wpdb->get_results($this->wpdb->prepare($query, $pagination_params)); |
| | | |
| | | // Format invitations for response |
| | | $formatted = []; |
| | | foreach ($invitations as $invitation) { |
| | | $formatted[] = $this->formatInvitation($invitation); |
| | | } |
| | | |
| | | $return = [ |
| | | 'invitations' => $formatted, |
| | | 'total' => (int)$total, |
| | | 'pages' => ceil($total / $per_page), |
| | | 'page' => $args['page'], |
| | | 'per_page' => $per_page |
| | | ]; |
| | | |
| | | $this->cache->set($key, $return); |
| | | |
| | | return new WP_REST_Response($return); |
| | | } |
| | | |
| | | /** |
| | | * Get a specific invitation by its ID |
| | | * |
| | | * @param int $invitationID The invitation ID to fetch |
| | | * @param string $role |
| | | * @return array|WP_Error The formatted invitation or an error |
| | | */ |
| | | protected function getInvitation(int $invitationID, string $role):array|WP_Error |
| | | { |
| | | // Validate invitation ID |
| | | $invitationID = intval($invitationID); |
| | | if (!$invitationID) { |
| | | return new WP_Error('invalid_id', 'Invalid invitation ID'); |
| | | } |
| | | |
| | | // Try to get from cache first |
| | | $cached = $this->cache->get($invitationID); |
| | | if ($cached) { |
| | | return $cached; |
| | | } |
| | | |
| | | // Query the database |
| | | $invitation = $this->wpdb->get_row($this->wpdb->prepare( |
| | | "SELECT * FROM {$this->tableNames[$role]} WHERE id = %d", |
| | | $invitationID |
| | | )); |
| | | |
| | | // Return error if not found |
| | | if (!$invitation) { |
| | | return new WP_Error('not_found', 'Invitation not found'); |
| | | } |
| | | |
| | | // Format the invitation for response |
| | | $formatted = $this->formatInvitation($invitation); |
| | | |
| | | // Cache the result |
| | | $this->cache->set($invitationID, $formatted); |
| | | |
| | | return $formatted; |
| | | } |
| | | |
| | | /** |
| | | * Get invitations for a specific user by their email or user ID |
| | | * |
| | | * @param int|string $identifier Either user ID or email of the invited person |
| | | * @param bool $include_token Whether to include the token in the response |
| | | * @return array|WP_Error The formatted invitations or an error |
| | | */ |
| | | public function getInvitationByUser(int|string $identifier):array|WP_Error |
| | | { |
| | | // Try to get from cache first |
| | | $cached = $this->cache->get($identifier); |
| | | if ($cached) { |
| | | return $cached; |
| | | } |
| | | global $wpdb; |
| | | |
| | | // Determine if we have a user ID or email |
| | | if (is_numeric($identifier)) { |
| | | // We have a user ID |
| | | $userID = intval($identifier); |
| | | |
| | | // Query by user ID |
| | | $invitation = $wpdb->get_row($wpdb->prepare( |
| | | "SELECT * FROM {$this->tableName} WHERE new_user_id = %d", |
| | | $userID |
| | | )); |
| | | } else { |
| | | // We have an email |
| | | $email = sanitize_email($identifier); |
| | | if (!is_email($email)) { |
| | | return new WP_Error('invalid_email', 'Invalid email address'); |
| | | } |
| | | |
| | | // Query by email |
| | | $invitation = $wpdb->get_row($wpdb->prepare( |
| | | "SELECT * FROM {$this->tableName} WHERE email = %s", |
| | | $email |
| | | )); |
| | | } |
| | | |
| | | // Return error if not found |
| | | if (!$invitation) { |
| | | return new WP_Error('not_found', 'No invitations found for this user'); |
| | | } |
| | | |
| | | // Format the invitation for response |
| | | $formattedInvitation = $this->formatInvitation($invitation); |
| | | |
| | | $this->cache->set($identifier, $formattedInvitation); |
| | | |
| | | return $formattedInvitation; |
| | | } |
| | | |
| | | /** |
| | | * Format invitation for API response |
| | | * @param object $invitation The invitation object |
| | | * @param bool $include_token whether or not to include the token in response |
| | | * @return array The formatted invitation |
| | | */ |
| | | protected function formatInvitation(object $invitation, bool $include_token = false):array |
| | | { |
| | | // Parse inviters JSON |
| | | $inviters = json_decode($invitation->inviters ?? '[]', true) ?: []; |
| | | |
| | | // Format inviters with names |
| | | $inviter_details = []; |
| | | foreach ($inviters as $inviter_id) { |
| | | $inviter_details[] = [ |
| | | 'id' => $inviter_id, |
| | | 'name' => jvbGetUsername($inviter_id) |
| | | ]; |
| | | } |
| | | |
| | | // Build formatted invitation |
| | | $formatted = [ |
| | | 'id' => $invitation->id, |
| | | 'name' => $invitation->name, |
| | | 'email' => $invitation->email, |
| | | 'status' => $invitation->status, |
| | | 'expires_at' => $invitation->expires_at, |
| | | 'accepted_at' => $invitation->accepted_at, |
| | | 'created_at' => $invitation->created_at, |
| | | 'updated_at' => $invitation->updated_at, |
| | | 'inviters' => $inviters |
| | | ]; |
| | | |
| | | // Include shop if assigned |
| | | if (!empty($invitation->to_shop)) { |
| | | $shop = get_term($invitation->to_shop, BASE . 'shop'); |
| | | if ($shop && !is_wp_error($shop)) { |
| | | $formatted['shop'] = [ |
| | | 'id' => $shop->term_id, |
| | | 'name' => $shop->name |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | // Include token if needed (only for validation) |
| | | if ($include_token) { |
| | | $formatted['token'] = $invitation->invitation_token; |
| | | } |
| | | |
| | | // Add registration URL for convenience |
| | | $formatted['registration_url'] = add_query_arg([ |
| | | 'token' => $invitation->invitation_token, |
| | | 'email' => urlencode($invitation->email) |
| | | ], home_url('/register/')); |
| | | |
| | | return $formatted; |
| | | } |
| | | |
| | | /** |
| | | * @param WP_Error|array $result The WP_Error to replace, if this is the operation type we're looking for |
| | | * @param object $operation The operation object |
| | | * @param array $data The data to process |
| | | * @return WP_Error|array WP_Error or array of processed data |
| | | * |
| | | */ |
| | | public function processOperation(WP_Error|array $result, object $operation, array $data):array|WP_Error |
| | | { |
| | | switch ($operation->type) { |
| | | case 'invitation_create': |
| | | return $this->processInvitations($data, $operation->user_id); |
| | | case 'invitation_revoke': |
| | | return $this->revokeInvite( |
| | | $data['invited'] |
| | | ); |
| | | } |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * Process a batch of invitations with transaction support |
| | | * |
| | | * @param array $data Array of invitation data ['role' => $invites ] |
| | | * @param int $user_id User ID of the inviter |
| | | * @return array Result data with success/failure information |
| | | */ |
| | | public function processInvitations(array $data, int $user_id):array |
| | | { |
| | | if (!$this->checkUser($user_id)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => 'Invalid User', |
| | | ]; |
| | | } |
| | | |
| | | // Start transaction |
| | | $this->wpdb->query('START TRANSACTION'); |
| | | |
| | | $results = [ |
| | | 'success' => [], |
| | | 'failed' => [] |
| | | ]; |
| | | |
| | | try { |
| | | foreach ($data as $role => $invitations) { |
| | | foreach ($invitations as $invite) { |
| | | if (!$invite['invited_name'] || !$invite['invited_email']) { |
| | | $results['failed'][] = [ |
| | | 'email' => $invite['invited_email'], |
| | | 'name' => $invite['invited_name'], |
| | | 'reason' => 'Invalid name or email' |
| | | ]; |
| | | continue; |
| | | } |
| | | |
| | | if ($invite['to_term'] && !$this->checkTerm($invite)) { |
| | | $results['failed'][] = [ |
| | | 'email' => $invite['invited_email'], |
| | | 'name' => $invite['invited_name'], |
| | | 'reason' => 'Invalid taxonomy to add to' |
| | | ]; |
| | | } |
| | | |
| | | // Create invitation (modify your existing method to avoid sending emails yet) |
| | | $result = $this->createInvitation($invite['invited_name'], $invite['invited_email'], $user_id, $role, $invite['to_term'], $invite['taxonomy'], false); |
| | | |
| | | if (is_wp_error($result)) { |
| | | $results['failed'][] = [ |
| | | 'email' => $invite['invited_email'], |
| | | 'name' => $invite['invited_name'], |
| | | 'reason' => $result->get_error_message() |
| | | ]; |
| | | } else { |
| | | $results['success'][] = [ |
| | | 'email' => $invite['invited_email'], |
| | | 'name' => $invite['invited_name'], |
| | | 'id' => $result['id'], |
| | | 'to_term' => $invite['to_term'], |
| | | 'taxonomy' => $invite['taxonomy'], |
| | | 'role' => $role, |
| | | 'expires_at' => $result['expires_at'] |
| | | ]; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // If we've processed at least one invitation successfully, commit |
| | | if (!empty($results['success'])) { |
| | | $this->wpdb->query('COMMIT'); |
| | | |
| | | // Now send emails for successful invitations |
| | | foreach ($results['success'] as $invitation) { |
| | | $this->sendInvitationEmail( |
| | | $invitation['name'], |
| | | $invitation['email'], |
| | | $invitation['token'], |
| | | $user_id, |
| | | [$invitation['taxonomy'] => $invitation['to_term']], |
| | | $invitation['role'] |
| | | ); |
| | | } |
| | | } else { |
| | | // No successful invitations, roll back |
| | | $this->wpdb->query('ROLLBACK'); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => count($results['success']) > count($results['failed']), |
| | | 'results' => $results |
| | | ]; |
| | | |
| | | } catch (Exception $e) { |
| | | // Handle error and roll back transaction |
| | | $this->wpdb->query('ROLLBACK'); |
| | | |
| | | JVB()->error()->log( |
| | | 'invitation_create', |
| | | 'Error processing batch invitations: ' . $e->getMessage(), |
| | | [ |
| | | 'user_id' => $user_id, |
| | | 'error' => $e->getMessage() |
| | | ], |
| | | 'error' |
| | | ); |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => [ |
| | | 'failed' => $invitations, |
| | | 'error' => $e->getMessage() |
| | | ] |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | public function modifyLoginLabels(array $labels, array $get_params): array |
| | | { |
| | | // Only modify if invitation params present |
| | | if (!array_key_exists('invite', $get_params) || !array_key_exists('email', $get_params)) { |
| | | return $labels; |
| | | } |
| | | $email = sanitize_email($get_params['email']); |
| | | $token = sanitize_text_field($get_params['invite']); |
| | | $user = email_exists($email); |
| | | if (!$user) { |
| | | return $labels; |
| | | } |
| | | $role = jvbUserRole($user); |
| | | // Get invitation data |
| | | $data = $this->verifyInvitation( |
| | | $token, |
| | | $email, |
| | | $role, |
| | | ); |
| | | |
| | | if (!$data) { |
| | | return $labels; |
| | | } |
| | | |
| | | // Build custom message |
| | | $inviters = json_decode($data->inviters, true); |
| | | $name = $data->name; |
| | | $names = array_map(function($inviter) { |
| | | $artist = jvbContentFromUser((int)$inviter['user_id']); |
| | | return $artist['name'] ?: $artist['display_name']; |
| | | }, $inviters); |
| | | |
| | | $message = count($names) > 1 |
| | | ? 'are already here, and have invited you to join in!' |
| | | : ' is already here, and invited you to join in!'; |
| | | |
| | | // Modify labels |
| | | $labels['title'] = 'Join the Scene, ' . $data->name; |
| | | $labels['description'] = [jvbCommaList($names) . ' ' . $message]; |
| | | |
| | | return $labels; |
| | | return $formatted; |
| | | } |
| | | |
| | | } |
| inc/rest/routes/LoginRoutes.php
inc/rest/routes/MagicLinkRoutes.php
inc/rest/routes/NewsRoutes.php
inc/rest/routes/NotificationsRoutes.php
inc/rest/routes/OptionsRoutes.php
inc/rest/routes/QueueRoutes.php
inc/rest/routes/ReferralRoutes.php
inc/rest/routes/ResponseRoutes.php
inc/rest/routes/SEORoutes.php
inc/rest/routes/SettingsRoutes.php
inc/rest/routes/ShopRoutes.php
inc/rest/routes/TermRoutes.php
inc/rest/routes/UploadRoutes.php
inc/rest/routes/VoteRoutes.php
inc/templates.php
inc/ui/CRUDSkeleton.php
src/summary/render.php
templates/dashboard/sections/news.php |