diff --git a/Extensions/discord.html b/Extensions/discord.html index b715e5a..0fda784 100644 --- a/Extensions/discord.html +++ b/Extensions/discord.html @@ -1,17 +1,17 @@ + (function() { + // On initial load, check if the screen is in portrait mode (height > width). + if (window.innerHeight > window.innerWidth) { + // Get the current URL's query string (e.g., "?sbn=...") + const queryString = window.location.search; + + // Construct the new URL for the mobile site, appending the query string. + const mobileUrl = './mobile/index.html' + queryString; + + // Redirect to the mobile site, preserving the URL parameters. + window.location.replace(mobileUrl); + } + })(); + diff --git a/Main/mobile/PWA/install-prompting.js b/Main/mobile/PWA/install-prompting.js new file mode 100644 index 0000000..3d13c86 --- /dev/null +++ b/Main/mobile/PWA/install-prompting.js @@ -0,0 +1,209 @@ +/** + * ********************************************************************************** + * Title: Safari Progressive Web App Installation System + * ********************************************************************************** + * @author Isaiah Tadrous + * @version 2.2.1 + * *------------------------------------------------------------------------------- + * Cross-platform PWA installation system specifically designed for Safari browsers. + * Automatically detects Safari environments and presents user-friendly installation + * prompts with step-by-step instructions for adding the web application to the + * device home screen. Features responsive design, modal overlays, and proper + * event handling for optimal user experience across iOS and macOS Safari. + * ********************************************************************************** + */ + +// Detect Safari browser environment and check installation status +const isSafariCheck = /^((?!chrome|android|crios|fxios|opios).)*safari/i.test(navigator.userAgent) && + navigator.vendor && navigator.vendor.indexOf('Apple') > -1; +const isInstalled = 'standalone' in window.navigator && window.navigator.standalone; + +if (isSafariCheck && !isInstalled) { + console.log('Safari detected - initializing install prompting'); + + let promptShown = false; + + function showPrompt() { + if (promptShown) return; + promptShown = true; + + // Remove any existing prompts + document.getElementById('safari-install-prompt')?.remove(); + + const prompt = document.createElement('div'); + prompt.id = 'safari-install-prompt'; + prompt.style.cssText = ` + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(135deg, #0976ea 0%, #0d47a1 100%); + color: white; + padding: 20px; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); + z-index: 10000; + animation: slideUp 0.4s ease-out; + width: 90%; + max-width: 850px; + `; + + prompt.innerHTML = ` +
+
+ + + + + +
+
+
Install StarBattle App
+
Get instant access and play offline!
+
+
+
+ + +
+ `; + + addStyles(); + document.body.appendChild(prompt); + + // Handle button clicks + prompt.querySelector('#install-yes').addEventListener('click', () => { + prompt.remove(); + showInstructions(); + }); + + prompt.querySelector('#install-no').addEventListener('click', () => { + prompt.remove(); // Dismiss prompt for current session without persistence + }); + } + + function showInstructions() { + const overlay = document.createElement('div'); + overlay.style.cssText = ` + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + z-index: 9998; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + backdrop-filter: blur(4px); + `; + + const modal = document.createElement('div'); + modal.style.cssText = ` + background: linear-gradient(135deg, #0976ea 0%, #0d47a1 100%); + color: white; + padding: 24px; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0,0,0,0.4); + width: 100%; + max-width: 500px; + animation: zoomIn 0.3s ease-out; + `; + + modal.innerHTML = ` +
+ + + + + +
+
Install Instructions
+
Follow these steps:
+
+
+
    +
  1. Tap the Share button at the bottom of Safari
  2. +
  3. Scroll down and tap "Add to Home Screen"
  4. +
  5. Tap "Add" to install the app
  6. +
+ + `; + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + // Handle close events + modal.querySelector('#close-instructions').addEventListener('click', () => overlay.remove()); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) overlay.remove(); + }); + } + + function addStyles() { + if (document.getElementById('install-styles')) return; + + const style = document.createElement('style'); + style.id = 'install-styles'; + style.textContent = ` + @keyframes slideUp { + from { transform: translateX(-50%) translateY(100%); opacity: 0; } + to { transform: translateX(-50%) translateY(0); opacity: 1; } + } + @keyframes zoomIn { + from { opacity: 0; transform: scale(0.9); } + to { opacity: 1; transform: scale(1); } + } + #install-yes:hover, #install-no:hover, #close-instructions:hover { + background: rgba(255,255,255,0.3) !important; + transform: translateY(-1px); + } + @media (max-width: 360px) { + #safari-install-prompt { + left: 10px !important; + right: 10px !important; + transform: none !important; + max-width: none !important; + } + } + `; + document.head.appendChild(style); + } + + function init() { + setTimeout(showPrompt, 700); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + // Development and debugging interface for prompt management + window.safariInstallPrompt = { + show: showPrompt, + stats: () => ({ + promptShown + }) + }; + +} else { + console.log('Not Safari or already installed - install prompting disabled'); +} diff --git a/Main/mobile/PWA/manifest.json b/Main/mobile/PWA/manifest.json index f624e1b..36371d4 100644 --- a/Main/mobile/PWA/manifest.json +++ b/Main/mobile/PWA/manifest.json @@ -1,21 +1,56 @@ { - "name": "StarBattle.org", - "short_name": "StarBattles", - "start_url": "index.html", - "display": "fullscreen", - "background_color": "#374151", - "theme_color": "#1F2937", - "description": "Starbattle.org Playground!", - "icons": [ - { - "src": "icons/icon-192x192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/icon-512x512.png", - "sizes": "512x512", - "type": "image/png" - } - ] + "name": "StarBattle.org", + "short_name": "StarBattle", + "start_url": "../index.html", + "scope": "../", + "display": "fullscreen", + "orientation": "portrait", + "background_color": "#1F2937", + "theme_color": "#1F2937", + "description": "Starbattle.org Playground!", + "categories": [ + "games", + "puzzle", + "logic", + "brain-training", + "queens", + "star", + "starbattle", + "starbattles" + ], + "icons": [ + { + "src": "../icons/favicon-96x96.png", + "sizes": "96x96", + "type": "image/png" + }, + { + "src": "../icons/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + }, + { + "src": "../icons/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "../icons/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "../icons/icon-dark-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "monochrome" + }, + { + "src": "../icons/favicon.svg", + "sizes": "any", + "type": "image/svg+xml" + } + ] } diff --git a/Main/mobile/PWA/pwa-manager.js b/Main/mobile/PWA/pwa-manager.js index 331a571..9173da2 100644 --- a/Main/mobile/PWA/pwa-manager.js +++ b/Main/mobile/PWA/pwa-manager.js @@ -3,76 +3,836 @@ * Title: PWA Management and Update Notification System * ********************************************************************************** * @author Isaiah Tadrous - * @version 1.1.0 + * @version 2.0.0 * *------------------------------------------------------------------------------- - * This script handles the registration of the service worker and manages the PWA - * update lifecycle. It detects when a new version of the service worker is - * available, caches it in the background, and then presents a notification to - * the user with an option to refresh the page to activate the new version. - * This ensures that users can seamlessly update to the latest version of the - * application without losing their current state unexpectedly. + * Comprehensive Progressive Web App management system that orchestrates service + * worker registration, update lifecycle management, and cross-session notification + * persistence. Provides seamless background update detection, intelligent caching + * strategies, and user friendly update prompts with automatic activation workflows. + * Features include custom installation prompts, periodic update checking, visibility + * change handling, and persistent state management across application sessions. + * Ensures optimal PWA experience with reliable offline functionality and smooth + * update deployment processes. * ********************************************************************************** */ -// --- PWA SERVICE WORKER REGISTRATION AND UPDATE --- +// --- GLOBAL VARIABLES --- + +let deferredPrompt; +let registration; +let updateIcon; +let waitingServiceWorker = null; + +const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; +const isSafari = /^((?!chrome|android|crios|fxios|opios).)*safari/i.test(navigator.userAgent); +const isSafariOnIOS = isIOS && isSafari && navigator.vendor && navigator.vendor.indexOf('Apple') > -1; + +// --- STORAGE KEYS --- +const STORAGE_KEYS = { + OUTDATED_FLAG: 'pwa_is_outdated', + UPDATE_AVAILABLE: 'pwa_update_available', + UPDATE_DISMISSED: 'pwa_update_dismissed' +}; + +// --- PERSISTENT UPDATE STATE MANAGEMENT --- + +/** + * Set outdated flag - app has an update waiting + */ +function setOutdatedFlag() { + try { + localStorage.setItem(STORAGE_KEYS.OUTDATED_FLAG, 'true'); + console.log('Outdated flag set - app needs update'); + } catch (e) { + console.warn('Could not set outdated flag:', e); + } +} + +/** + * Clear outdated flag - app is up to date + */ +function clearOutdatedFlag() { + try { + localStorage.removeItem(STORAGE_KEYS.OUTDATED_FLAG); + console.log('Outdated flag cleared - app is up to date'); + } catch (e) { + console.warn('Could not clear outdated flag:', e); + } +} + +/** + * Check if app is outdated + */ +function isAppOutdated() { + try { + return localStorage.getItem(STORAGE_KEYS.OUTDATED_FLAG) === 'true'; + } catch (e) { + return false; + } +} + +/** + * Perform update by reloading the page + * This clears the cache and activates any waiting service worker + */ +function performUpdate() { + console.log('Performing app update...'); + clearOutdatedFlag(); + + // Try to message any waiting service worker first + if (waitingServiceWorker) { + try { + waitingServiceWorker.postMessage({ + action: 'skipWaiting' + }); + } catch (e) { + console.warn('Could not message waiting worker:', e); + } + } + + // Always reload to ensure update + setTimeout(() => { + window.location.reload(true); + }, 100); +} + +/** + * Store update availability state + */ +function setUpdateAvailable(available) { + try { + if (available) { + sessionStorage.setItem(STORAGE_KEYS.UPDATE_AVAILABLE, 'true'); + sessionStorage.removeItem(STORAGE_KEYS.UPDATE_DISMISSED); + } else { + sessionStorage.removeItem(STORAGE_KEYS.UPDATE_AVAILABLE); + sessionStorage.removeItem(STORAGE_KEYS.UPDATE_DISMISSED); + } + } catch (e) { + console.warn('Could not access sessionStorage:', e); + } +} /** - * Registers the service worker and sets up the update notification system. - * This function is the entry point for all PWA-related functionality. - * @returns {void} + * Check if update is available */ -function registerServiceWorker() { - // Check if service workers are supported by the browser +function isUpdateAvailable() { + try { + return sessionStorage.getItem(STORAGE_KEYS.UPDATE_AVAILABLE) === 'true'; + } catch (e) { + return false; + } +} + +/** + * Mark update as dismissed for this session + */ +function setUpdateDismissed() { + try { + sessionStorage.setItem(STORAGE_KEYS.UPDATE_DISMISSED, 'true'); + } catch (e) { + console.warn('Could not access sessionStorage:', e); + } +} + +/** + * Check if update was dismissed + */ +function isUpdateDismissed() { + try { + return sessionStorage.getItem(STORAGE_KEYS.UPDATE_DISMISSED) === 'true'; + } catch (e) { + return false; + } +} + +/** + * Check for waiting service worker on startup + */ +function checkForWaitingServiceWorker() { + if (registration && registration.waiting) { + console.log('Found waiting service worker on startup'); + waitingServiceWorker = registration.waiting; + setUpdateAvailable(true); + + if (!isUpdateDismissed()) { + showUpdateNotification(waitingServiceWorker); + } else { + showUpdateIcon(); + } + return true; + } + return false; +} + +/** + * service worker registration with immediate update check + */ +async function registerServiceWorker() { if ('serviceWorker' in navigator) { - // Register the service worker using a relative path and scope it to the current directory. - navigator.serviceWorker.register('service-worker.js', { scope: '.' }).then(registration => { + try { + registration = await navigator.serviceWorker.register('./service-worker.js', { + scope: './', + updateViaCache: 'none' + }); + console.log('Service Worker registered with scope:', registration.scope); - // Listen for updates to the service worker - registration.addEventListener('updatefound', () => { - const newWorker = registration.installing; - if (newWorker) { - newWorker.addEventListener('statechange', () => { - // When the new service worker is installed and waiting, show the update notification - if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { - showUpdateNotification(newWorker); - } - }); + + // Set up event listeners + registration.addEventListener('updatefound', handleUpdateFound); + + // Check for waiting service worker immediately + setTimeout(() => { + if (!checkForWaitingServiceWorker()) { + // No waiting worker, check for service worker updates + checkForUpdates(); } - }); - }).catch(error => { + }, 1000); + + // Check if there's already an update available from previous session + if (isUpdateAvailable() && !isUpdateDismissed()) { + if (registration.waiting) { + waitingServiceWorker = registration.waiting; + showUpdateNotification(waitingServiceWorker); + } else { + showUpdateIcon(); + } + } + + } catch (error) { console.error('Service Worker registration failed:', error); - }); + } } } /** - * Displays a notification to the user when a new version of the app is ready. - * The notification includes a button that allows the user to activate the new - * service worker, which will then reload the page to apply the updates. - * @param {ServiceWorker} newWorker - The new service worker that is waiting to be activated. - * @returns {void} + * Show update icon when notification is dismissed + */ +function showUpdateIcon() { + updateIcon = createUpdateIcon(); + updateIcon.style.display = 'flex'; + console.log('Update icon shown'); +} + +/** + * Update notification that persists state */ function showUpdateNotification(newWorker) { + // Store the waiting worker reference + waitingServiceWorker = newWorker; + setUpdateAvailable(true); + + const existingNotification = document.getElementById('update-notification'); + const notification = existingNotification || document.createElement('div'); + + if (!existingNotification) { + notification.id = 'update-notification'; + notification.style.cssText = ` + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(135deg, #0976ea 0%, #0d47a1 100%); + color: white; + padding: 20px; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); + z-index: 9999; + animation: pwa-slideInUp 0.3s ease-out; + width: 90%; + max-width: 850px; + text-align: center; + `; + + document.body.appendChild(notification); + } + + notification.style.display = 'block'; + + notification.innerHTML = ` + + +
+

A new version is available!

+ +
+ `; + + // Add CSS if needed + if (!document.getElementById('update-notification-styles')) { + const style = document.createElement('style'); + style.id = 'update-notification-styles'; + style.textContent = ` + @keyframes pwa-slideInUp { + from { + transform: translate(-50%, 100%); + opacity: 0; + } + to { + transform: translate(-50%, 0); + opacity: 1; + } + } + #reload-button:hover { + background: rgba(255,255,255,0.3) !important; + } + #dismiss-update:hover { + opacity: 1 !important; + background-color: rgba(255,255,255,0.2) !important; + } + `; + document.head.appendChild(style); + } + + // Create update icon but keep it hidden initially + updateIcon = createUpdateIcon(); + updateIcon.onclick = () => { + notification.style.display = 'block'; + updateIcon.style.display = 'none'; + console.log('Update icon clicked, showing notification'); + }; + + // Enhanced event handlers + const reloadButton = notification.querySelector('#reload-button'); + const dismissButton = notification.querySelector('#dismiss-update'); + + if (reloadButton) { + reloadButton.addEventListener('click', (e) => { + e.stopPropagation(); + + reloadButton.textContent = 'Installing...'; + reloadButton.disabled = true; + reloadButton.style.opacity = '0.7'; + + // Perform the update using our self-contained method + performUpdate(); + }); + } + + if (dismissButton) { + dismissButton.addEventListener('click', (e) => { + e.stopPropagation(); + notification.style.display = 'none'; + setUpdateDismissed(); // Persist dismissal state + showUpdateIcon(); + console.log('Update notification dismissed, showing update icon'); + }); + } + + // Auto-dismiss after 30 seconds but remember it was dismissed + setTimeout(() => { + if (notification.style.display !== 'none') { + notification.style.display = 'none'; + setUpdateDismissed(); + showUpdateIcon(); + console.log('Update notification auto-dismissed, showing update icon'); + } + }, 30000); +} + +/** + * Enhanced update found handler + */ +function handleUpdateFound() { + console.log('New service worker version found, installing...'); + const newWorker = registration.installing; + + if (newWorker) { + newWorker.addEventListener('statechange', () => { + console.log('New service worker state:', newWorker.state); + + if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { + // Set the persistent outdated flag + setOutdatedFlag(); + + console.log('App is now marked as outdated. Showing notification.'); + waitingServiceWorker = newWorker; + showUpdateNotification(newWorker); + } + }); + } +} + +/** + * Check for updates with better error handling + */ +async function checkForUpdates() { + if (registration) { + try { + console.log('Checking for service worker updates...'); + const updatedReg = await registration.update(); + + // Check if there's a waiting worker after update + if (updatedReg.waiting && navigator.serviceWorker.controller) { + waitingServiceWorker = updatedReg.waiting; + if (!isUpdateDismissed()) { + showUpdateNotification(waitingServiceWorker); + } else { + showUpdateIcon(); + } + } + } catch (error) { + console.error('Failed to check for updates:', error); + } + } +} + +/** + * Visibility change handler + */ +function handleVisibilityChange() { + if (!document.hidden) { + console.log('App became visible, checking for updates...'); + + // Check for waiting worker first + if (!checkForWaitingServiceWorker()) { + // Perform additional update check for new service worker versions + checkForUpdates(); + } + } +} + +/** + * PWA initialization + */ +async function initializePWA() { + console.log('Initializing enhanced PWA functionality...'); + + // Clear any stale update state on fresh app start + if (performance.navigation.type === performance.navigation.TYPE_RELOAD) { + setUpdateAvailable(false); + } + + // Register the service worker + await registerServiceWorker(); + + // Check for persistent outdated flag on every startup + if (isAppOutdated()) { + console.log('Outdated flag found on startup. Showing update icon.'); + showUpdateIcon(); // Immediately show the small icon + } + + const installButton = document.getElementById('install-pwa-btn'); + if (installButton && !isSafariOnIOS) { + installButton.addEventListener('click', handleInstallClick); + } + + // Event listeners + document.addEventListener('visibilitychange', handleVisibilityChange); + + window.addEventListener('focus', () => { + console.log('App gained focus, checking for updates...'); + if (!checkForWaitingServiceWorker()) { + checkForUpdates(); + } + }); + + // Check for updates periodically (every 30 minutes when active) + setInterval(() => { + if (!document.hidden) { + checkForUpdates(); + } + }, 30 * 60 * 1000); + + if (isStandalone()) { + console.log('App is running in standalone mode'); + if (installButton) { + installButton.style.display = 'none'; + } + } + + console.log('Enhanced PWA initialization complete'); +} + +// --- PWA CUSTOM INSTALL PROMPT CODE --- +window.addEventListener('beforeinstallprompt', (e) => { + if (isSafariOnIOS) { + return; + } + + console.log('App is installable - showing custom install prompt.'); + e.preventDefault(); + deferredPrompt = e; + + setTimeout(showCustomInstallPrompt, 1000); +}); + +function showCustomInstallPrompt() { + if (isSafariOnIOS) return; + + if (document.getElementById('custom-install-prompt')) return; + + const promptDiv = document.createElement('div'); + promptDiv.id = 'custom-install-prompt'; + promptDiv.style.cssText = ` + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(135deg, #0976ea 0%, #0d47a1 100%); + color: white; + padding: 20px; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); + z-index: 100; + animation: pwa-slideInUp 0.4s ease-out; + width: min(550px, calc(100vw - 40px)); + `; + + promptDiv.innerHTML = ` +
+
+ +
+
+
Install StarBattle App
+
Get instant access and play offline!
+
+
+
+ + +
+ `; + + if (!document.getElementById('install-prompt-styles')) { + const style = document.createElement('style'); + style.id = 'install-prompt-styles'; + style.textContent = ` + @keyframes pwa-slideInUp { + from { transform: translateX(-50%) translateY(100%); opacity: 0; } + to { transform: translateX(-50%) translateY(0); opacity: 1; } + } + `; + document.head.appendChild(style); + } + + document.body.appendChild(promptDiv); + + document.getElementById('custom-install-accept').addEventListener('click', () => { + promptDiv.remove(); + handleInstallClick(); + }); + + document.getElementById('custom-install-dismiss').addEventListener('click', () => { + promptDiv.remove(); + }); +} + +function handleInstallClick() { + const installButton = document.getElementById('install-pwa-btn'); + + if (deferredPrompt) { + deferredPrompt.prompt(); + + deferredPrompt.userChoice.then((choiceResult) => { + console.log(`User response to the install prompt: ${choiceResult.outcome}`); + + if (choiceResult.outcome === 'accepted') { + console.log('User accepted the install prompt'); + showInstallSuccessMessage(); + } else { + console.log('User dismissed the install prompt'); + } + + deferredPrompt = null; + + if (installButton) { + installButton.style.display = 'none'; + } + }).catch((error) => { + console.error('Error during install prompt:', error); + }); + } else { + console.log('No deferred prompt available'); + } +} + +function showInstallSuccessMessage() { + const message = document.createElement('div'); + message.style.cssText = ` + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + background-color: #10b981; + color: white; + padding: 15px 25px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + z-index: 1001; + font-size: 1rem; + font-weight: 500; + `; + message.textContent = 'App installed successfully! All puzzles are now available offline.'; + document.body.appendChild(message); + + setTimeout(() => { + message.remove(); + }, 4000); +} + +window.addEventListener('appinstalled', (e) => { + console.log('PWA was successfully installed'); + deferredPrompt = null; + + const installButton = document.getElementById('install-pwa-btn'); + if (installButton) { + installButton.style.display = 'none'; + } + + showInstallSuccessMessage(); +}); + +function createUpdateIcon() { + if (document.getElementById('update-icon')) { + return document.getElementById('update-icon'); + } + + const iconDiv = document.createElement('div'); + iconDiv.id = 'update-icon'; + iconDiv.style.cssText = ` + position: fixed; + bottom: 20px; + right: 20px; + width: 60px; + height: 60px; + background: linear-gradient(135deg, #0976ea 0%, #0d47a1 100%); + color: white; + border-radius: 50%; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + z-index: 9998; + display: none; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; + border: none; + outline: none; + `; + + iconDiv.innerHTML = ` + + + + + `; + + document.body.appendChild(iconDiv); + + // Handle click - show notification or directly update + iconDiv.addEventListener('click', () => { + if (isAppOutdated()) { + // Show the notification for confirmation + showUpdateNotificationForIcon(); + } + }); + + iconDiv.addEventListener('mouseover', () => { + iconDiv.style.transform = 'scale(1.1)'; + iconDiv.style.boxShadow = '0 6px 18px rgba(0,0,0,0.4)'; + }); + + iconDiv.addEventListener('mouseout', () => { + iconDiv.style.transform = 'scale(1)'; + iconDiv.style.boxShadow = '0 4px 12px rgba(0,0,0,0.3)'; + }); + + return iconDiv; +} + +/** + * Show update notification when icon is clicked + */ +function showUpdateNotificationForIcon() { + const existingNotification = document.getElementById('update-notification'); + if (existingNotification) { + existingNotification.style.display = 'block'; + updateIcon.style.display = 'none'; + return; + } + + // Create a simple notification for icon clicks const notification = document.createElement('div'); notification.id = 'update-notification'; + notification.style.cssText = ` + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(135deg, #0976ea 0%, #0d47a1 100%); + color: white; + padding: 20px; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); + z-index: 9999; + animation: pwa-slideInUp 0.3s ease-out; + width: 90%; + max-width: 850px; + text-align: center; + `; + notification.innerHTML = ` -

A new version is available!

- + + +
+

A new version is available!

+ +
`; + + if (!document.getElementById('update-notification-styles')) { + const style = document.createElement('style'); + style.id = 'update-notification-styles'; + style.textContent = ` + @keyframes pwa-slideInUp { + from { + transform: translate(-50%, 100%); + opacity: 0; + } + to { + transform: translate(-50%, 0); + opacity: 1; + } + } + #reload-button:hover { + background: rgba(255,255,255,0.3) !important; + } + #dismiss-update:hover { + opacity: 1 !important; + background-color: rgba(255,255,255,0.2) !important; + } + `; + document.head.appendChild(style); + } + document.body.appendChild(notification); - // Add a click listener to the "Update Now" button - const reloadButton = document.getElementById('reload-button'); + updateIcon.style.display = 'none'; + + // Add event handlers + const reloadButton = notification.querySelector('#reload-button'); + const dismissButton = notification.querySelector('#dismiss-update'); + if (reloadButton) { - reloadButton.addEventListener('click', () => { - // Send a message to the new service worker to skip waiting and activate immediately - newWorker.postMessage({ action: 'skipWaiting' }); - // Reload the page to apply the update - window.location.reload(); + reloadButton.addEventListener('click', (e) => { + e.stopPropagation(); + reloadButton.textContent = 'Installing...'; + reloadButton.disabled = true; + reloadButton.style.opacity = '0.7'; + performUpdate(); + }); + } + + if (dismissButton) { + dismissButton.addEventListener('click', (e) => { + e.stopPropagation(); + notification.style.display = 'none'; + setUpdateDismissed(); + showUpdateIcon(); + console.log('Update notification dismissed, showing update icon'); }); } } -// --- INITIALIZATION --- +function isStandalone() { + return window.matchMedia('(display-mode: standalone)').matches || + window.navigator.standalone || + document.referrer.includes('android-app://'); +} + +// Initialize when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializePWA); +} else { + initializePWA(); +} -// Register the service worker as soon as the script is loaded -registerServiceWorker(); +// Export functions for debugging +if (typeof window !== 'undefined') { + window.PWAManager = { + checkForUpdates, + isStandalone, + showCustomInstallPrompt: !isSafariOnIOS ? showCustomInstallPrompt : null, + showUpdateIcon, + createUpdateIcon, + setUpdateAvailable, + isUpdateAvailable, + checkForWaitingServiceWorker, + setOutdatedFlag, + clearOutdatedFlag, + isAppOutdated, + performUpdate + }; +} diff --git a/Main/mobile/app.init.js b/Main/mobile/app.init.js index a1f1f92..3c5b48e 100644 --- a/Main/mobile/app.init.js +++ b/Main/mobile/app.init.js @@ -3,16 +3,14 @@ * Title: Star Battle Application Initializer and Event Wiring * ********************************************************************************** * @author Isaiah Tadrous - * @version 1.0.3 + * @version 1.1.0 * *------------------------------------------------------------------------------- * This script serves as the main entry point for the Star Battle web application. * It waits for the DOM to be fully loaded and then executes the primary `init` - * function. This function is responsible for wiring up all interactive UI - * elements to their corresponding logic handlers. It defines a custom helper - * for creating responsive event listeners that work seamlessly on both touch - * and mouse-based devices. It connects all buttons, modals, settings toggles, - * and the main grid interaction events to the appropriate functions, effectively - * bootstrapping the entire application and making it ready for user interaction. + * function. It now checks for a shared puzzle in the URL using the '?sbn=' format + * to modify the home screen state. It is responsible for wiring up all interactive + * UI elements to their corresponding logic handlers and generating share links + * in the specified format. * ********************************************************************************** */ @@ -25,6 +23,15 @@ document.addEventListener('DOMContentLoaded', () => { * @returns {void} */ function init() { + // Detect a puzzle from the URL using the 'sbn' parameter + const urlParams = new URLSearchParams(window.location.search); + const puzzleFromUrl = urlParams.get('sbn'); + if (puzzleFromUrl) { + state.puzzleFromUrl = decodeURIComponent(puzzleFromUrl); + // Clean up the URL so it's not reused on refresh + window.history.replaceState({}, document.title, window.location.pathname); + } + // --- RESPONSIVE EVENT LISTENER HELPER --- /** @@ -64,16 +71,40 @@ document.addEventListener('DOMContentLoaded', () => { // --- MAIN UI EVENT WIRING --- loadSettings(); - + updateHomeScreenForSharedPuzzle(); // Update home screen if a puzzle was found in the URL + + // Initialize the puzzle selection state to match the default index, if no selection was loaded + if (!state.currentPuzzleSelection.dim && !state.currentPuzzleSelection.stars && !state.currentPuzzleSelection.difficulty) { + const defaultPuzzleDef = state.puzzleDefs[state.selectedPuzzleIndex]; + if (defaultPuzzleDef) { + const initialSelection = { + dim: defaultPuzzleDef.dim, + stars: defaultPuzzleDef.stars, + difficulty: getPuzzleDifficultyName(defaultPuzzleDef) + }; + state.currentPuzzleSelection = { ...initialSelection }; + state.effectivePuzzleSelection = { ...initialSelection }; + } + } + // Populate the puzzle size selector dropdown on startup - populateSizeSelector(); + populatePuzzleSelectorModal(); // Initialize the import UI when the app loads setupImportInterface({ importPuzzleString, setStatus }); // Wire up all the primary action buttons to their respective functions addResponsiveListener(backToHomeBtn, showHomeScreen); - addResponsiveListener(newPuzzleBtn, fetchNewPuzzle); + + // The "New Puzzle" button now has conditional logic + addResponsiveListener(newPuzzleBtn, () => { + if (state.puzzleFromUrl) { + importPuzzleString(state.puzzleFromUrl); + } else { + fetchNewPuzzle(); + } + }); + addResponsiveListener(savePuzzleBtn, handleSave); addResponsiveListener(checkSolutionBtn, () => checkSolution(true)); addResponsiveListener(importBtn, handleImport); @@ -146,11 +177,22 @@ document.addEventListener('DOMContentLoaded', () => { } // --- MODAL AND MENU EVENT LISTENERS --- + + addResponsiveListener(openPuzzleSelectBtn, () => puzzleSelectModal.classList.remove('hidden')); + addResponsiveListener(puzzleSelectModalCloseBtn, () => puzzleSelectModal.classList.add('hidden')); // Wire up mode-switching buttons and modal triggers addResponsiveListener(markModeBtn, () => switchMode('mark')); addResponsiveListener(drawModeBtn, () => switchMode('draw')); addResponsiveListener(borderModeBtn, () => switchMode('border')); + addResponsiveListener(drawEraserBtn, () => { + state.isDrawEraserActive = !state.isDrawEraserActive; + updateModeUI(); + }); + addResponsiveListener(borderEraserBtn, () => { + state.isBorderEraserActive = !state.isBorderEraserActive; + updateModeUI(); + }); addResponsiveListener(findSolutionBtn, handleSolutionToggle); addResponsiveListener(loadPuzzleBtn, () => { populateLoadModal(); @@ -184,6 +226,14 @@ document.addEventListener('DOMContentLoaded', () => { autoXAroundToggle.addEventListener('change', (e) => { state.autoXAroundStars = e.target.checked; saveSettings();}); autoXMaxLinesToggle.addEventListener('change', (e) => { state.autoXOnMaxLines = e.target.checked; saveSettings();}); autoXMaxRegionsToggle.addEventListener('change', (e) => { state.autoXOnMaxRegions = e.target.checked; saveSettings();}); + showTimerToggle.addEventListener('change', (e) => { + state.showTimer = e.target.checked; + saveSettings(); + // If a game is active, immediately apply the change. + if (state.timerInterval) { + gameTimer.classList.toggle('hidden', !state.showTimer); + } + }); // --- LOAD/SAVE MODAL EVENT LISTENERS --- @@ -289,6 +339,77 @@ document.addEventListener('DOMContentLoaded', () => { // Resize the canvas whenever the browser window is resized window.addEventListener('resize', resizeCanvas); + + // --- SUCCESS MODAL EVENT LISTENERS --- + + addResponsiveListener(playAnotherBtn, () => { + hideSuccessModal(); + puzzleSelectModal.classList.add('hidden'); + + // ALWAYS use the user's current selection from the home screen. + // This ensures "Random Puzzle" stays random. + state.effectivePuzzleSelection = { ...state.currentPuzzleSelection }; + + fetchNewPuzzle(); + state.lastSolvedPuzzle = null; // Clean up the state for the next round. + }); + + addResponsiveListener(changeLevelBtn, () => { + // Just open the selector on top; do not hide the success modal. + puzzleSelectModal.classList.remove('hidden'); + }); + + addResponsiveListener(goHomeBtn, () => { + state.lastSolvedPuzzle = null; // Clean up state + hideSuccessModal(); + showHomeScreen(); + }); + + addResponsiveListener(successModalCloseBtn, () => { + state.lastSolvedPuzzle = null; // Clean up state + hideSuccessModal(); + }); + + // Close modal if user clicks on the backdrop + successModal.addEventListener('click', (e) => { + if (e.target === successModal) { + state.lastSolvedPuzzle = null; // Clean up state + hideSuccessModal(); + } + }); + + // Prevent clicks inside the modal content from closing it + const successModalContent = successModal.querySelector('div'); + if (successModalContent) { + successModalContent.addEventListener('click', (e) => e.stopPropagation()); + } + + // Share button logic + addResponsiveListener(shareSuccessBtn, () => { + const time = timeTakenEl.textContent; + // Create a shareable URL with the new format + const puzzleToShare = encodeURIComponent(state.puzzleId); + const shareUrl = `https://www.starbattle.org/Main/?sbn=${puzzleToShare}`; + const shareText = `I just solved a ${state.gridDim}x${state.gridDim} Star Battle puzzle in ${time} on starbattle.org! Try it yourself:`; + + if (navigator.share) { + navigator.share({ + title: 'Star Battle Puzzle Solved!', + text: shareText, + url: shareUrl, + }).catch(err => console.log("Share API cancelled or failed.", err)); + } else { + // Fallback for desktop/browsers that don't support Web Share API + navigator.clipboard.writeText(`${shareText} ${shareUrl}`).then(() => { + hideSuccessModal(); + setStatus("Share link copied to clipboard!", true, 2500); + }).catch(err => { + hideSuccessModal(); + setStatus("Could not copy link.", false, 2500); + console.error('Fallback copy failed:', err); + }); + } + }); // --- FINAL INITIALIZATION --- diff --git a/Main/mobile/dom.elements.js b/Main/mobile/dom.elements.js index c36c16b..ac29ed2 100644 --- a/Main/mobile/dom.elements.js +++ b/Main/mobile/dom.elements.js @@ -3,7 +3,7 @@ * Title: Star Battle UI Element References * ********************************************************************************** * @author Isaiah Tadrous - * @version 1.0.0 + * @version 1.1.0 * *------------------------------------------------------------------------------- * This script centralizes all DOM element references for the Star Battle web * application. It queries the document to obtain and store references to all @@ -30,7 +30,6 @@ const contextualControls = document.getElementById('contextual-controls'); // Wr // Core game components const highlightErrorsToggle = document.getElementById('highlight-errors-toggle'); // The checkbox input for the "Highlight Errors" setting. const gridContainer = document.getElementById('grid-container'); // The main `div` element that holds the puzzle grid cells. -const sizeSelect = document.getElementById('size-select'); // The dropdown menu for selecting a new puzzle size. const solverStatus = document.getElementById('solver-status'); // The text element used to display status messages to the user. const loadingSpinner = document.getElementById('loading-spinner'); // The element for the loading animation, shown during API calls. const drawCanvas = document.getElementById('draw-canvas'); // The HTML canvas element used for drawing overlays. @@ -61,6 +60,9 @@ const brushSizeWrapper = document.getElementById('brush-size-wrapper'); // The c const colorPickerWrapper = document.getElementById('color-picker-wrapper'); // The container for all color selection UI. const htmlColorPicker = document.getElementById('html-color-picker'); // The native HTML input[type=color] element. const customColorBtn = document.getElementById('custom-color-btn'); // The button to trigger the color picker for a custom color. +const borderToolsWrapper = document.getElementById('border-tools-wrapper'); // The container for border-specific tools like the eraser. +const borderEraserBtn = document.getElementById('border-eraser-btn'); // The button to toggle border erasing. +const drawEraserBtn = document.getElementById('draw-eraser-btn'); // Settings modal elements const settingsBtn = document.getElementById('settings-btn'); // Button to open the settings modal. @@ -70,12 +72,35 @@ const bwModeToggle = document.getElementById('bw-mode-toggle'); // Toggle switch const autoXAroundToggle = document.getElementById('auto-x-around-toggle'); // Toggle for auto-X'ing around stars. const autoXMaxLinesToggle = document.getElementById('auto-x-max-lines-toggle'); // Toggle for auto-X'ing full rows/columns. const autoXMaxRegionsToggle = document.getElementById('auto-x-max-regions-toggle'); // Toggle for auto-X'ing full regions. +const showTimerToggle = document.getElementById('show-timer-toggle'); // Toggle for showing the timer. // Load/Save modal elements const loadModal = document.getElementById('load-modal'); // The main container for the load puzzle modal. const modalContent = document.getElementById('modal-content'); // The area within the modal where saved games are listed. const modalCloseBtn = document.getElementById('modal-close-btn'); // The close button for the load puzzle modal. +// New puzzle selector elements +const openPuzzleSelectBtn = document.getElementById('open-puzzle-select-btn'); +const puzzleSelectDisplay = document.getElementById('puzzle-select-display'); +const puzzleSelectModal = document.getElementById('puzzle-select-modal'); +const puzzleSelectModalCloseBtn = document.getElementById('puzzle-select-modal-close-btn'); +const puzzleSizeGrid = document.getElementById('puzzle-size-grid'); +const puzzleStarCountList = document.getElementById('puzzle-star-count-list'); +const puzzleDifficultyList = document.getElementById('puzzle-difficulty-list'); + + // Responsive UI elements const hamburgerMenuBtn = document.getElementById('hamburger-menu-btn'); // The hamburger menu button for mobile view. -const puzzleActionsTab = document.getElementById('puzzle-actions-tab'); // The slide-out panel containing puzzle actions on mobile. \ No newline at end of file +const puzzleActionsTab = document.getElementById('puzzle-actions-tab'); // The slide-out panel containing puzzle actions on mobile. + +// Success Modal Elements +const successModal = document.getElementById('success-modal'); +const successModalCloseBtn = document.getElementById('success-modal-close-btn'); +const shareSuccessBtn = document.getElementById('share-success-btn'); +const timeTakenEl = document.getElementById('time-taken'); +const gameTimer = document.getElementById('game-timer'); +const puzzleDifficulty = document.getElementById('puzzle-difficulty'); +const puzzleSizeInfo = document.getElementById('puzzle-size-info'); +const playAnotherBtn = document.getElementById('play-another-btn'); +const changeLevelBtn = document.getElementById('change-level-btn'); +const goHomeBtn = document.getElementById('go-home-btn'); diff --git a/Main/mobile/engine.logic.js b/Main/mobile/engine.logic.js index cbe7fdd..284e327 100644 --- a/Main/mobile/engine.logic.js +++ b/Main/mobile/engine.logic.js @@ -3,7 +3,7 @@ * Title: Star Battle Game Logic and History Management * ********************************************************************************** * @author Isaiah Tadrous - * @version 1.0.2 + * @version 1.1.0 * *------------------------------------------------------------------------------- * This script contains the core game logic and state manipulation functions for * the Star Battle puzzle application. It is responsible for handling player @@ -181,6 +181,7 @@ function removeStarAndUndoAutoX(r, c) { pushHistory({ type: 'compoundMark', changes: [change] }); } } + performAutoCheck({ r, c }); updateErrorHighlightingUI(); } @@ -333,8 +334,12 @@ function undo() { state.customBorders.pop(); redrawAllOverlays(); break; - case 'removeCellFromBorder': - state.customBorders[change.borderIndex].path.add(change.cell); + case 'compoundEraseBorder': + change.changes.forEach(c => { + if (state.customBorders[c.borderIndex]) { + state.customBorders[c.borderIndex].path.add(c.cell); + } + }); redrawAllOverlays(); break; case 'clearMarks': @@ -385,8 +390,12 @@ function redo() { state.customBorders.push(change.border); redrawAllOverlays(); break; - case 'removeCellFromBorder': - state.customBorders[change.borderIndex].path.delete(change.cell); + case 'compoundEraseBorder': + change.changes.forEach(c => { + if (state.customBorders[c.borderIndex]) { + state.customBorders[c.borderIndex].path.delete(c.cell); + } + }); redrawAllOverlays(); break; case 'clearMarks': @@ -456,7 +465,8 @@ function saveSettings() { autoXOnMaxLines: state.autoXOnMaxLines, autoXOnMaxRegions: state.autoXOnMaxRegions, markIsX: state.markIsX, - + showTimer: state.showTimer, + currentPuzzleSelection: state.currentPuzzleSelection, }; localStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(settingsToSave)); } catch (error) { @@ -486,6 +496,7 @@ function loadSettings() { autoXAroundToggle.checked = state.autoXAroundStars; autoXMaxLinesToggle.checked = state.autoXOnMaxLines; autoXMaxRegionsToggle.checked = state.autoXOnMaxRegions; + showTimerToggle.checked = state.showTimer; // Update other UI elements toggleMarkBtn.textContent = state.markIsX ? "Dots" : "Xs"; diff --git a/Main/mobile/icons/apple-touch-icon.png b/Main/mobile/icons/apple-touch-icon.png new file mode 100644 index 0000000..91fa085 Binary files /dev/null and b/Main/mobile/icons/apple-touch-icon.png differ diff --git a/Main/mobile/icons/favicon-96x96.png b/Main/mobile/icons/favicon-96x96.png new file mode 100644 index 0000000..b812363 Binary files /dev/null and b/Main/mobile/icons/favicon-96x96.png differ diff --git a/Main/mobile/icons/favicon.ico b/Main/mobile/icons/favicon.ico new file mode 100644 index 0000000..04b09ad Binary files /dev/null and b/Main/mobile/icons/favicon.ico differ diff --git a/Main/mobile/icons/favicon.svg b/Main/mobile/icons/favicon.svg new file mode 100644 index 0000000..4367182 --- /dev/null +++ b/Main/mobile/icons/favicon.svg @@ -0,0 +1,17 @@ + \ No newline at end of file diff --git a/Main/mobile/icons/icon-192x192.png b/Main/mobile/icons/icon-192x192.png deleted file mode 100644 index 651679e..0000000 Binary files a/Main/mobile/icons/icon-192x192.png and /dev/null differ diff --git a/Main/mobile/icons/icon-512x512.png b/Main/mobile/icons/icon-512x512.png deleted file mode 100644 index 4ecd580..0000000 Binary files a/Main/mobile/icons/icon-512x512.png and /dev/null differ diff --git a/Main/mobile/icons/icon-dark-512x512.png b/Main/mobile/icons/icon-dark-512x512.png new file mode 100644 index 0000000..e771d8b Binary files /dev/null and b/Main/mobile/icons/icon-dark-512x512.png differ diff --git a/Main/mobile/icons/web-app-manifest-192x192.png b/Main/mobile/icons/web-app-manifest-192x192.png new file mode 100644 index 0000000..1c7e948 Binary files /dev/null and b/Main/mobile/icons/web-app-manifest-192x192.png differ diff --git a/Main/mobile/icons/web-app-manifest-512x512.png b/Main/mobile/icons/web-app-manifest-512x512.png new file mode 100644 index 0000000..ef20679 Binary files /dev/null and b/Main/mobile/icons/web-app-manifest-512x512.png differ diff --git a/Main/mobile/index.html b/Main/mobile/index.html index 361cc23..a3d2d26 100644 --- a/Main/mobile/index.html +++ b/Main/mobile/index.html @@ -3,7 +3,7 @@ * =============================================================================== * * @author Isaiah Tadrous - * @version 1.1.5 + * @version 1.2.0 * * ------------------------------------------------------------------------------ * Description: @@ -23,17 +23,21 @@ + - + Starbattle.org - - - + + + + + + + - - - + @@ -88,25 +92,26 @@
-

Star Battle

-

A Logic Puzzle Playground

+

Starbattle.org

+

Interactive Playground!

- +
- - - - + + +
@@ -115,7 +120,7 @@

Star Battle

-
+

@@ -123,10 +128,22 @@

Star Battle

-
- -
-
+
+
+
+

+
+ +
+
+ +
+

+
+
+
+
@@ -145,7 +162,6 @@

Star Battle

-
+
@@ -202,6 +236,33 @@

Star Battle

+ + +