diff --git a/disciple-tools-dashboard.php b/disciple-tools-dashboard.php index 776b732..2116c0c 100755 --- a/disciple-tools-dashboard.php +++ b/disciple-tools-dashboard.php @@ -214,6 +214,13 @@ private function setup_actions() { */ public function register_tiles() { $tiles = DT_Dashboard_Plugin_Tiles::instance(); + + // Register Home Apps tile first (template will handle conditional display) + $tiles->register( new DT_Dashboard_Plugin_Tile( 'DT_Dashboard_Plugin_Home_Apps', __( 'Your Apps', 'disciple-tools-dashboard' ), [ + 'priority' => -1, + 'span' => 4 + ] ) ); + $tiles->register( new DT_Dashboard_Plugin_Tile( 'DT_Dashboard_Plugin_Active_Contact', __( 'Active Contacts', 'disciple-tools-dashboard' ), [ 'priority' => 2 ] ) ); $tiles->register( new DT_Dashboard_Plugin_Tile( 'DT_Dashboard_Plugin_Update_Needed', __( 'Update Needed', 'disciple-tools-dashboard' ), [ 'priority' => 1 ] ) ); $tiles->register( new DT_Dashboard_Plugin_Tile( 'DT_Dashboard_Plugin_Pending_Contacts', __( 'Pending Contacts', 'disciple-tools-dashboard' ), [ 'priority' => 0 ] ) ); diff --git a/includes/tiles/home-apps/scripts.js b/includes/tiles/home-apps/scripts.js new file mode 100644 index 0000000..5f4f189 --- /dev/null +++ b/includes/tiles/home-apps/scripts.js @@ -0,0 +1,291 @@ +(function ($) { + // Debounce function for resize events + function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + + window.dt_dashboard.onAdd("DT_Dashboard_Plugin_Home_Apps", function (context) { + const carouselElement = $(context.element).find('.home-apps-carousel'); + const carouselWrapper = $(context.element).find('.home-apps-carousel-wrapper'); + const spinnerElement = $(context.element).find('.stats-spinner'); + + // Get apps data from data attribute + const appsData = carouselElement.data('apps'); + + // Get Show More URL from data attribute + // Use .attr() to get the raw value, then check if it's empty + const showMoreUrlRaw = carouselElement.attr('data-show-more-url'); + const showMoreUrl = showMoreUrlRaw && showMoreUrlRaw !== '' ? showMoreUrlRaw : null; + + if (!appsData || !Array.isArray(appsData) || appsData.length === 0) { + spinnerElement.removeClass('active'); + carouselElement.html('
No apps available.
'); + return; + } + + // Hide spinner + spinnerElement.removeClass('active'); + + // Render apps with dynamic limiting + function renderWithLimit() { + renderApps(appsData, carouselElement, carouselWrapper, showMoreUrl); + } + + // Initial render + renderWithLimit(); + + // Handle window resize + const handleResize = debounce(function() { + renderWithLimit(); + }, 300); + + $(window).on('resize', handleResize); + }); + + /** + * Validate URL to prevent XSS attacks + * Only allows http://, https://, relative URLs, and protocol-relative URLs + * Blocks dangerous schemes like javascript:, data:, vbscript:, etc. + * + * @param {string} url The URL to validate + * @return {boolean} True if URL is safe, false otherwise + */ + function isValidUrl(url) { + if (!url || url === '#' || url.trim() === '') { + return false; + } + + const trimmedUrl = url.trim(); + const lowerUrl = trimmedUrl.toLowerCase(); + + // Block dangerous URL schemes that could execute code + const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:', 'about:']; + for (const scheme of dangerousSchemes) { + if (lowerUrl.startsWith(scheme)) { + return false; + } + } + + // Allow http://, https://, relative URLs (starting with /), and protocol-relative URLs (starting with //) + const validPattern = /^(https?:\/\/|\/\/|\/|#)/i; + return validPattern.test(trimmedUrl); + } + + /** + * Escape URL for safe use in HTML onclick attribute + * Validates URL scheme and properly encodes it + * + * @param {string} url The URL to escape + * @return {string} Escaped URL or '#' if invalid + */ + function escapeUrlForOnclick(url) { + // Validate URL first + if (!isValidUrl(url)) { + return '#'; + } + + // Use encodeURI for proper URL encoding, then escape for JavaScript string in HTML attribute + try { + const encoded = encodeURI(url); + return encoded.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"'); + } catch (e) { + // If encoding fails, return safe fallback + return '#'; + } + } + + /** + * Calculate how many cards can fit in the visible area + */ + function calculateVisibleCards(wrapper) { + if (!wrapper || wrapper.length === 0) { + return 5; // Default fallback + } + + const wrapperWidth = wrapper[0].getBoundingClientRect().width; + if (wrapperWidth <= 0) { + return 5; // Default fallback + } + + // Get responsive card width and gap based on window size + let cardWidth = 75; // Desktop default + let gap = 16; // 1rem = 16px default + const windowWidth = window.innerWidth; + + if (windowWidth <= 480) { + cardWidth = 60; + gap = 8; // 0.5rem + } else if (windowWidth <= 640) { + cardWidth = 65; + gap = 12; // 0.75rem + } + + // Account for padding (left and right) + let padding = 40; // 20px each side default + if (windowWidth <= 480) { + padding = 20; // 10px each side + } else if (windowWidth <= 640) { + padding = 30; // 15px each side + } + + // Calculate available width + const availableWidth = wrapperWidth - padding; + + // Calculate how many cards fit (including gap between cards) + // Formula: (availableWidth + gap) / (cardWidth + gap) + const cardsThatFit = Math.floor((availableWidth + gap) / (cardWidth + gap)); + + // Return at least 1 card, and subtract 1 for the "Show More" card + return Math.max(1, cardsThatFit - 1); + } + + /** + * Create app card HTML + */ + function createAppCard(app, isHidden) { + // Use full title - CSS will handle wrapping + const trimmedTitle = app.title || ''; + + // Always open apps in a new tab + const appUrl = app.url || '#'; + + // Validate and escape URL to prevent XSS attacks + let onClickHandler = ''; + const safeUrl = escapeUrlForOnclick(appUrl); + if (safeUrl === '#') { + // Disable click for invalid/empty URLs + onClickHandler = `onclick="return false;"`; + } else { + // Use validated and escaped URL + onClickHandler = `onclick="window.open('${safeUrl}', '_blank'); return false;"`; + } + + // Determine icon display: image or icon class + let iconHtml = ''; + const isImageIcon = app.icon && (app.icon.startsWith('http') || app.icon.startsWith('/')); + + if (isImageIcon) { + // Render image icon + const safeIconUrl = (app.icon || '').replace(/"/g, '"'); + const safeTitle = trimmedTitle.replace(/"/g, '"'); + iconHtml = `${safeTitle}`; + } else { + // Render icon class with color support + let iconColor = null; + const hasCustomColor = app.color && typeof app.color === 'string' && app.color.trim() !== ''; + + // Validate hex color format + if (hasCustomColor) { + const hexColorPattern = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/; + if (hexColorPattern.test(app.color.trim())) { + iconColor = app.color.trim(); + } + } + + // Use theme-aware default if no valid custom color + if (!iconColor) { + // Check for dark mode (dashboard may not have theme-dark class, so check body background) + const bodyBg = window.getComputedStyle(document.body).backgroundColor; + const isDarkMode = bodyBg && ( + bodyBg.includes('rgb(26, 26, 26)') || + bodyBg.includes('rgb(42, 42, 42)') || + document.body.classList.contains('theme-dark') || + document.documentElement.classList.contains('theme-dark') + ); + iconColor = isDarkMode ? '#ffffff' : '#0a0a0a'; + } + + const safeIcon = (app.icon || 'mdi mdi-apps').replace(/"/g, '"'); + iconHtml = ``; + } + + const safeTitle = (trimmedTitle || '').replace(/"/g, '"'); + const hiddenClass = isHidden ? ' hidden' : ''; + + return ` +
+
+
+ ${iconHtml} +
+
+
${safeTitle}
+
+ `; + } + + /** + * Create "Show More" card HTML + */ + function createShowMoreCard(url) { + // Validate URL and check if it's enabled + const isEnabled = url && url !== null && url !== undefined && url !== '' && url !== 'null' && isValidUrl(url); + + // Build click handler or disable card + let onClickHandler = ''; + let disabledClass = ''; + let tooltip = 'Show More'; + + if (isEnabled) { + // Card is enabled - validate and escape URL to prevent XSS attacks + const safeUrl = escapeUrlForOnclick(url); + onClickHandler = `onclick="window.open('${safeUrl}', '_blank'); return false;"`; + } else { + // Card is disabled - no click handler, add disabled class + disabledClass = ' disabled'; + tooltip = 'Please Activate Home Screen'; + onClickHandler = `onclick="return false;"`; + } + + // Use blue background and white icon (moved to CSS) + const iconHtml = ''; + + return ` +
+
+
+ ${iconHtml} +
+
+
Show More
+
+ `; + } + + /** + * Render apps in carousel format with dynamic limiting + */ + function renderApps(apps, container, wrapper, showMoreUrl) { + let html = ''; + + // Calculate how many cards can be visible + const visibleLimit = calculateVisibleCards(wrapper); + + // Render visible apps (limit - 1 to make room for "Show More") + const appsToShow = Math.min(visibleLimit, apps.length); + + // Render visible app cards + for (let i = 0; i < appsToShow; i++) { + html += createAppCard(apps[i], false); + } + + // Always add "Show More" card as the last visible card + html += createShowMoreCard(showMoreUrl); + + // Render remaining apps as hidden (for scrolling) + for (let i = appsToShow; i < apps.length; i++) { + html += createAppCard(apps[i], true); + } + + container.html(html); + } +})(window.jQuery); + diff --git a/includes/tiles/home-apps/style.css b/includes/tiles/home-apps/style.css new file mode 100644 index 0000000..31b59ff --- /dev/null +++ b/includes/tiles/home-apps/style.css @@ -0,0 +1,368 @@ +/** + * Home Apps Tile Styles + * + * Styles for the Home Apps carousel tile in the dashboard. + */ + +/* Full-width tile override */ +.dashboard-page #dash-tile--DT_Dashboard_Plugin_Home_Apps { + grid-column: 1 / -1; /* Span all columns */ +} + +/* Hide tile header and footer */ +/* Using !important to override dashboard default styles that may have higher specificity */ +.dashboard-page #dash-tile--DT_Dashboard_Plugin_Home_Apps .tile-header, +.dashboard-page #dash-tile--DT_Dashboard_Plugin_Home_Apps .tile-footer { + display: none !important; +} + +/* Title styling in tile body */ +.dashboard-page #dash-tile--DT_Dashboard_Plugin_Home_Apps .home-apps-title { + margin: 0 0 1rem 0; + font-size: 1.4rem; + font-weight: 800; + color: var(--primary-color); + padding: 0; + display: inline-block; +} + +/* Spinner positioning */ +.dashboard-page #dash-tile--DT_Dashboard_Plugin_Home_Apps .tile-body--home-apps .stats-spinner { + display: inline-block; + margin-left: 0.5rem; + vertical-align: middle; +} + +/* Carousel wrapper - horizontal scroll container */ +.home-apps-carousel-wrapper { + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; + width: 100%; + max-width: 100%; /* Ensure it doesn't exceed parent width */ + box-sizing: border-box; /* Include padding in width calculations */ + padding: 0; + margin: 0; + /* Ensure proper touch scrolling on mobile */ + touch-action: pan-x; + /* Show scrollbar when content overflows */ + scrollbar-width: thin; /* Firefox - show thin scrollbar */ + scrollbar-color: var(--gray) transparent; /* Firefox scrollbar color */ +} + +.home-apps-carousel-wrapper::-webkit-scrollbar { + height: 6px; /* Show scrollbar on Chrome/Safari */ +} + +.home-apps-carousel-wrapper::-webkit-scrollbar-track { + background: transparent; +} + +.home-apps-carousel-wrapper::-webkit-scrollbar-thumb { + background-color: var(--gray); + border-radius: 3px; +} + +.home-apps-carousel-wrapper::-webkit-scrollbar-thumb:hover { + background-color: var(--secondary-color); +} + +/* Carousel content - flex container */ +.home-apps-carousel { + display: flex; + gap: 1rem; + padding: 1rem 0; + /* Allow content to determine width, but don't force parent expansion */ + width: max-content; + min-width: 100%; /* At minimum, fill parent width */ + /* Add padding on sides for better mobile scrolling */ + padding-left: 0; + padding-right: 0; + box-sizing: border-box; +} + +/* Empty state */ +.home-apps-empty { + text-align: center; + padding: 2rem; + color: var(--secondary-color); + font-size: 0.9rem; +} + +/* App card wrapper - matches Home Screen structure */ +.home-apps-carousel .app-card-wrapper { + flex: 0 0 auto; /* Don't shrink */ + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + width: 75px; /* Match home screen size */ + max-width: 75px; /* Ensure it doesn't exceed width */ + box-sizing: border-box; +} + +/* Hidden app cards (for scrolling) */ +.home-apps-carousel .app-card-wrapper.hidden { + display: none; +} + +/* App card - matches Home Screen styling */ +.home-apps-carousel .app-card { + background: var(--white); + border: 1px solid var(--gray); + border-radius: 24px; + padding: 0.6rem; + text-align: center; + transition: all 0.2s ease; + cursor: pointer; + text-decoration: none; + color: var(--primary-color); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + aspect-ratio: 1; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1); + width: 75px; + margin-bottom: 0.5rem; +} + +.home-apps-carousel .app-card:hover { + transform: scale(1.05); + box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.15); + border-color: var(--blue); + text-decoration: none; + color: var(--primary-color); +} + +/* Show More card - blue background with white icon */ +.home-apps-carousel .app-card.show-more-card { + background: var(--blue); + border-color: var(--blue); +} + +.home-apps-carousel .app-card.show-more-card:hover { + background: var(--blue); + border-color: var(--blue); + transform: scale(1.05); + box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.15); +} + +/* Show More card icon styling */ +.home-apps-carousel .show-more-card .app-icon { + color: #ffffff; +} + +/* Increased specificity to override any icon color styles without !important */ +.home-apps-carousel .app-card.show-more-card .app-icon i.show-more-icon, +.home-apps-carousel .show-more-card .app-icon i { + color: #ffffff; +} + +/* Disabled Show More card - grayed out and non-clickable */ +.home-apps-carousel .app-card.show-more-card.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + background: var(--blue); + border-color: var(--blue); +} + +.home-apps-carousel .app-card.show-more-card.disabled:hover { + transform: none; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1); +} + +/* App icon container */ +.home-apps-carousel .app-icon { + font-size: 2.1rem; + margin-bottom: 0; + color: var(--primary-color); + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + transition: all 0.2s ease; +} + +.home-apps-carousel .app-icon i { + font-size: 2.1rem; + line-height: 1; +} + +.home-apps-carousel .app-icon img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1)); +} + +.home-apps-carousel .app-card:hover .app-icon { + transform: scale(1.05); +} + +/* App title */ +.home-apps-carousel .app-title { + font-size: 0.75rem; + font-weight: 500; + padding: 0; + color: var(--primary-color); + line-height: 1.2; + max-width: 75px; + width: 100%; + display: block; + text-align: center; + word-wrap: break-word; + overflow-wrap: break-word; + hyphens: auto; + /* Allow text to wrap to multiple lines */ + white-space: normal; +} + +/* Responsive adjustments - Mobile (max-width: 640px) */ +@media screen and (max-width: 640px) { + .dashboard-page #dash-tile--DT_Dashboard_Plugin_Home_Apps { + grid-column: 1 / -1; /* Ensure full width on mobile */ + } + + .home-apps-carousel { + gap: 0.75rem; + padding: 0.75rem 0; + } + + .home-apps-carousel .app-card-wrapper { + width: 65px; + } + + .home-apps-carousel .app-card { + width: 65px; + padding: 0.5rem; + border-radius: 20px; + } + + .home-apps-carousel .app-icon { + width: 40px; + height: 40px; + } + + .home-apps-carousel .app-icon i { + font-size: 1.75rem; + } + + .home-apps-carousel .app-title { + font-size: 0.65rem; + max-width: 65px; + white-space: normal; + word-wrap: break-word; + overflow-wrap: break-word; + } + + /* Adjust title for mobile */ + .dashboard-page #dash-tile--DT_Dashboard_Plugin_Home_Apps .home-apps-title { + font-size: 1rem; + margin-bottom: 0.75rem; + } +} + +/* Responsive adjustments - Small Mobile (max-width: 480px) */ +@media screen and (max-width: 480px) { + .home-apps-carousel { + gap: 0.5rem; + padding: 0.5rem 0; + } + + .home-apps-carousel .app-card-wrapper { + width: 60px; + } + + .home-apps-carousel .app-card { + width: 60px; + padding: 0.4rem; + border-radius: 18px; + } + + .home-apps-carousel .app-icon { + width: 36px; + height: 36px; + } + + .home-apps-carousel .app-icon i { + font-size: 1.6rem; + } + + .home-apps-carousel .app-title { + font-size: 0.6rem; + max-width: 60px; + white-space: normal; + word-wrap: break-word; + overflow-wrap: break-word; + } + + .dashboard-page #dash-tile--DT_Dashboard_Plugin_Home_Apps .home-apps-title { + font-size: 0.9rem; + margin-bottom: 0.5rem; + } +} + +/* Responsive adjustments - Tablet (max-width: 972px) */ +@media screen and (max-width: 972px) { + .dashboard-page #dash-tile--DT_Dashboard_Plugin_Home_Apps { + grid-column: 1 / -1; /* Ensure full width on tablet */ + } + + .dashboard-page #dash-tile--DT_Dashboard_Plugin_Home_Apps .home-apps-title { + font-size: 1.2rem; + } +} + +/* Responsive adjustments - Tablet and up (min-width: 700px) */ +@media screen and (min-width: 700px) { + .home-apps-carousel { + gap: 1rem; + padding: 1rem 0; + } +} + +/* Responsive adjustments - Desktop (min-width: 1000px) */ +@media screen and (min-width: 1000px) { + .dashboard-page #dash-tile--DT_Dashboard_Plugin_Home_Apps { + grid-column: span 4; /* Explicitly span 4 columns on desktop */ + } +} + +/* Ensure tile body has proper padding and doesn't overflow */ +.dashboard-page #dash-tile--DT_Dashboard_Plugin_Home_Apps .tile-body--home-apps { + padding: 20px; + width: 100%; + max-width: 100%; + box-sizing: border-box; + overflow: hidden; /* Prevent tile body from expanding */ +} + +@media screen and (max-width: 640px) { + .dashboard-page #dash-tile--DT_Dashboard_Plugin_Home_Apps .tile-body--home-apps { + padding: 15px; + } +} + +@media screen and (max-width: 480px) { + .dashboard-page #dash-tile--DT_Dashboard_Plugin_Home_Apps .tile-body--home-apps { + padding: 10px; + } +} + +/* Ensure the tile itself doesn't overflow */ +.dashboard-page #dash-tile--DT_Dashboard_Plugin_Home_Apps { + max-width: 100%; + box-sizing: border-box; + overflow: hidden; /* Prevent tile from expanding beyond viewport */ +} + +.dashboard-page #dash-tile--DT_Dashboard_Plugin_Home_Apps .tile { + max-width: 100%; + box-sizing: border-box; + overflow: hidden; /* Prevent card from expanding */ +} + diff --git a/includes/tiles/home-apps/template.php b/includes/tiles/home-apps/template.php new file mode 100644 index 0000000..7847000 --- /dev/null +++ b/includes/tiles/home-apps/template.php @@ -0,0 +1,69 @@ +#dash-tile--' . esc_attr( $tile->handle ) . ' { display: none !important; }'; + return; +} + +// Get apps via direct method call +$apps = DT_Home_Apps::instance()->get_apps_for_frontend( 'app' ); + +// Ensure apps is an array +if ( !is_array( $apps ) ) { + $apps = []; +} + +// Only render if apps exist, otherwise hide the tile completely +if ( empty( $apps ) ) { + // Hide the entire tile wrapper - using !important to override any conflicting styles + echo ''; + return; +} + +// Get Home Screen app URL for "Show More" card +$app_key = 'apps_launcher_magic_key'; +$app_user_key = get_user_option( $app_key ); + +// Construct URL if user has activated the Home Screen app +$show_more_url = false; +if ( $app_user_key && !empty( $app_user_key ) ) { + // Try to get URL base from apps_list filter, fallback to hardcoded value + $apps_list = apply_filters( 'dt_settings_apps_list', [] ); + $url_base = 'apps/launcher'; // Default fallback + if ( isset( $apps_list[$app_key]['url_base'] ) ) { + $url_base = $apps_list[$app_key]['url_base']; + } + $show_more_url = trailingslashit( trailingslashit( site_url() ) . $url_base ) . $app_user_key; +} +?> + +
+

label ); ?>

+
+
+ +
+ diff --git a/includes/tiles/plugin-tile.php b/includes/tiles/plugin-tile.php index b8d8e2d..16dba31 100644 --- a/includes/tiles/plugin-tile.php +++ b/includes/tiles/plugin-tile.php @@ -16,6 +16,7 @@ public function __construct( $handle, $label, $span = 1 ) { */ public function setup() { $script = 'includes/tiles/' . $this->template_folder . '/scripts.js'; + $style = 'includes/tiles/' . $this->template_folder . '/style.css'; if ( file_exists( DT_Dashboard_Plugin::dir() . $script ) ) { wp_enqueue_script( $this->handle, DT_Dashboard_Plugin::path() . $script, [ @@ -30,6 +31,10 @@ public function setup() { 'moment' ], filemtime( DT_Dashboard_Plugin::dir() . $script ), true); } + + if ( file_exists( DT_Dashboard_Plugin::dir() . $style ) ) { + wp_enqueue_style( $this->handle . '-style', DT_Dashboard_Plugin::path() . $style, [], filemtime( DT_Dashboard_Plugin::dir() . $style ) ); + } } /**