1<!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <meta name="hydra-site-id" content="btfkih7o"> 7 8 <!-- Primary Meta Tags --> 9 <title>Podcast</title> 10 <meta name="description" content="Podcast â Simon Akam."> 11 <meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"> 12 <meta name="author" content="simonakam.com"> 13 <meta name="language" content="en"> 14 15 <!-- Canonical URL --> 16 <link rel="canonical" href="https://simonakam.com/podcast"> 17 18 <!-- Favicon --> 19 20 21 22 23 24 <!-- Open Graph / Facebook / iMessage / Discord --> 25 <meta property="og:type" content="article"> 26 <meta property="og:url" content="https://simonakam.com/podcast"> 27 <meta property="og:title" content="Podcast"> 28 <meta property="og:description" content="Podcast â Simon Akam."> 29 <meta property="og:site_name" content="simonakam.com"> 30 <meta property="og:locale" content="en_US"> 31 32 33 34 35 36 37 38 <!-- Twitter / X Card --> 39 <meta name="twitter:card" content="summary_large_image"> 40 <meta name="twitter:site" content="@simonakam.com"> 41 <meta name="twitter:url" content="https://simonakam.com/podcast"> 42 <meta name="twitter:title" content="Podcast"> 43 <meta name="twitter:description" content="Podcast â Simon Akam."> 44 45 46 47 <!-- Additional SEO Meta Tags --> 48 <meta name="theme-color" content="#000000"> 49 <meta name="msapplication-TileColor" content="#000000"> 50 <meta name="apple-mobile-web-app-capable" content="yes"> 51 <meta name="apple-mobile-web-app-status-bar-style" content="default"> 52 <meta name="apple-mobile-web-app-title" content="Podcast"> 53 54 <!-- Security Headers --> 55 <meta http-equiv="X-Content-Type-Options" content="nosniff"> 56 <meta http-equiv="X-XSS-Protection" content="1; mode=block"> 57 <style> 58 /* FOUC gate: opacity on <html> cannot be pierced by child visibility:!important (submenu CSS) */ 59 html:not(.hydra-ready) { opacity: 0 !important; } 60 /* Critical: hide nested menus before menu-styles.css arrives */ 61 li:not(.expanded) > ol.nested-sortable { display: none !important; } 62 .hydra-seo-nav { position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0; } 63 .hydra-seo-nav ul { list-style:none;margin:0;padding:0; } 64 .hydra-skip-link { position:absolute;left:-10000px;top:auto;width:1px;height:1px;overflow:hidden; } 65 </style> 66 <noscript><style>html { opacity: 1 !important; }</style></noscript> 67
67<script type="application/ld+json"> 68 { 69 "@context": "https://schema.org", 70 "@type": "WebPage", 71 "name": "Podcast", 72 "description": "Podcast â Simon Akam.", 73 "url": "https://simonakam.com/podcast", 74 "datePublished": "2026-10-01T14:53:34.373Z", 75 "dateModified": "2026-10-01T14:53:34.373Z", 76 "inLanguage": "en-US", 77 "isPartOf": { 78 "@type": "WebSite", 79 "@id": "https://simonakam.com/#website", 80 "name": "simonakam.com", 81 "url": "https://simonakam.com/", 82 "description": "Podcast â Simon Akam.", 83 "publisher": { 84 "@type": "Organization", 85 "name": "simonakam.com", 86 "url": "https://simonakam.com/" 87 } 88 }, 89 "breadcrumb": { 90 "@type": "BreadcrumbList", 91 "itemListElement": [ 92 { 93 "@type": "ListItem", 94 "position": 1, 95 "name": "Home", 96 "item": "https://simonakam.com/" 97 }, 98 { 99 "@type": "ListItem", 100 "position": 2, 101 "name": "Podcast", 102 "item": "https://simonakam.com/podcast" 103 } 104 ] 105 } 106} 107 </script>
107 108 109 110 <!-- Preview mode helper script --> 111
111<script> 112 // Immediately execute to patch request methods before any other script runs 113 (function() { 114 // Detect preview mode by checking hostname 115 const isPreviewMode = window.location.hostname === 'preview.neonsky.app'; 116 117 if (isPreviewMode) { 118 console.log("[Preview Helper] Preview mode detected"); 119 120 // Extract GUID from URL path (first segment) 121 const pathParts = window.location.pathname.split('/').filter(Boolean); 122 const previewGuid = pathParts.length > 0 ? pathParts[0] : ''; 123 124 if (!previewGuid) { 125 console.warn("[Preview Helper] No GUID found in path:", window.location.pathname); 126 return; // Exit if no GUID found 127 } 128 129 console.log("[Preview Helper] Using GUID:", previewGuid); 130 131 // Store these in window for other scripts 132 window.isPreviewMode = true; 133 window.previewGuid = previewGuid; 134 window.isHydraEnvironment = true; 135 window.Parameters = window.Parameters || {}; 136 window.Parameters.isHydra = true; 137 138 // Helper function that adds the GUID to API URLs and relative links 139 window.getApiUrl = function(endpoint) { 140 // For relative paths 141 if (endpoint.startsWith('/api/')) { 142 const newUrl = '/' + previewGuid + endpoint; 143 console.log("[Preview Helper] Transformed API URL:", endpoint, "â", newUrl); 144 return newUrl; 145 } 146 return endpoint; 147 }; 148 149 // Helper function that adds the GUID to any relative link 150 window.getPreviewUrl = function(url) { 151 // For relative paths (starts with /) that aren't already prefixed with GUID 152 if (url.startsWith('/') && !url.startsWith('/' + previewGuid + '/')) { 153 const newUrl = '/' + previewGuid + url; 154 console.log("[Preview Helper] Transformed relative link:", url, "â", newUrl); 155 return newUrl; 156 } 157 return url; 158 }; 159 160 // PATCH FETCH API with enhanced URL handling 161 const originalFetch = window.fetch; 162 window.fetch = function(resource, init) { 163 // Only transform string URLs 164 if (typeof resource === 'string') { 165 // Case 1: Handle relative paths starting with /api/ 166 if (resource.startsWith('/api/')) { 167 const newUrl = window.getApiUrl(resource); 168 console.log("[Preview Helper] Patched fetch call (API):", resource, "â", newUrl); 169 return originalFetch(newUrl, init); 170 } 171 172 // Case 2: Handle other relative paths (for content/gallery links) 173 else if (resource.startsWith('/') && !resource.startsWith('/' + previewGuid + '/')) { 174 const newUrl = window.getPreviewUrl(resource); 175 console.log("[Preview Helper] Patched fetch call (relative):", resource, "â", newUrl); 176 return originalFetch(newUrl, init); 177 } 178 179 // Case 3: Handle absolute URLs to the same domain containing /api/ 180 else if (resource.includes(window.location.host) && resource.includes('/api/')) { 181 try { 182 const urlObj = new URL(resource); 183 if (urlObj.hostname === window.location.hostname) { 184 const path = urlObj.pathname; 185 // Already has /{guid}/api/... (e.g. from new URL(getApiUrl(...), origin)) â do not double-prefix 186 if (path.startsWith('/' + previewGuid + '/')) { 187 return originalFetch(resource, init); 188 } 189 const newPath = '/' + previewGuid + path; 190 urlObj.pathname = newPath; 191 const newUrl = urlObj.toString(); 192 console.log("[Preview Helper] Patched fetch call (absolute API):", resource, "â", newUrl); 193 return originalFetch(newUrl, init); 194 } 195 } catch (e) { 196 console.error("[Preview Helper] Error processing URL:", e); 197 } 198 } 199 200 // Case 4: Handle absolute URLs to the same domain (non-API) 201 else if (resource.includes(window.location.host)) { 202 try { 203 const urlObj = new URL(resource); 204 if (urlObj.hostname === window.location.hostname && 205 !urlObj.pathname.startsWith('/' + previewGuid + '/')) { 206 const path = urlObj.pathname; 207 const newPath = '/' + previewGuid + path; 208 urlObj.pathname = newPath; 209 const newUrl = urlObj.toString(); 210 console.log("[Preview Helper] Patched fetch call (absolute):", resource, "â", newUrl); 211 return originalFetch(newUrl, init); 212 } 213 } catch (e) { 214 console.error("[Preview Helper] Error processing URL:", e); 215 } 216 } 217 } 218 return originalFetch(resource, init); 219 }; 220 console.log("[Preview Helper] Patched fetch API"); 221 222 // PATCH XMLHttpRequest with enhanced URL handling 223 const originalOpen = XMLHttpRequest.prototype.open; 224 XMLHttpRequest.prototype.open = function(method, url, ...rest) { 225 // Only transform string URLs 226 if (typeof url === 'string') { 227 // Case 1: Handle relative paths starting with /api/ 228 if (url.startsWith('/api/')) { 229 const newUrl = window.getApiUrl(url); 230 console.log("[Preview Helper] Patched XMLHttpRequest (API):", url, "â", newUrl); 231 return originalOpen.call(this, method, newUrl, ...rest); 232 } 233 234 // Case 2: Handle other relative paths 235 else if (url.startsWith('/') && !url.startsWith('/' + previewGuid + '/')) { 236 const newUrl = window.getPreviewUrl(url); 237 console.log("[Preview Helper] Patched XMLHttpRequest (relative):", url, "â", newUrl); 238 return originalOpen.call(this, method, newUrl, ...rest); 239 } 240 241 // Case 3: Handle absolute URLs to the same domain containing /api/ 242 else if (url.includes(window.location.host) && url.includes('/api/')) { 243 try { 244 const urlObj = new URL(url); 245 if (urlObj.hostname === window.location.hostname) { 246 const path = urlObj.pathname; 247 if (path.startsWith('/' + previewGuid + '/')) { 248 return originalOpen.call(this, method, url, ...rest); 249 } 250 const newPath = '/' + previewGuid + path; 251 urlObj.pathname = newPath; 252 const newUrl = urlObj.toString(); 253 console.log("[Preview Helper] Patched XMLHttpRequest (absolute API):", url, "â", newUrl); 254 return originalOpen.call(this, method, newUrl, ...rest); 255 } 256 } catch (e) { 257 console.error("[Preview Helper] Error processing URL:", e); 258 } 259 } 260 261 // Case 4: Handle absolute URLs to the same domain (non-API) 262 else if (url.includes(window.location.host)) { 263 try { 264 const urlObj = new URL(url); 265 if (urlObj.hostname === window.location.hostname && 266 !urlObj.pathname.startsWith('/' + previewGuid + '/')) { 267 const path = urlObj.pathname; 268 const newPath = '/' + previewGuid + path; 269 urlObj.pathname = newPath; 270 const newUrl = urlObj.toString(); 271 console.log("[Preview Helper] Patched XMLHttpRequest (absolute):", url, "â", newUrl); 272 return originalOpen.call(this, method, newUrl, ...rest); 273 } 274 } catch (e) { 275 console.error("[Preview Helper] Error processing URL:", e); 276 } 277 } 278 } 279 return originalOpen.call(this, method, url, ...rest); 280 }; 281 console.log("[Preview Helper] Patched XMLHttpRequest"); 282 283 // Note: Link click handling is done by the existing gallery link interceptor 284 // The fetch and XMLHttpRequest patches above handle programmatic requests 285 286 // Add debugging aid 287 window.debugPreviewUrls = function() { 288 return { 289 hostname: window.location.hostname, 290 pathname: window.location.pathname, 291 guid: window.previewGuid, 292 isPreviewMode: window.isPreviewMode, 293 sampleTransformations: { 294 relativeApi: window.getApiUrl('/api/menu-styles'), 295 relativeLink: window.getPreviewUrl('/gallery'), 296 absolute: new URL('/api/save-config', window.location.origin).toString().replace('/api/', '/' + previewGuid + '/api/') 297 } 298 }; 299 }; 300 301 // Test the transformation immediately 302 console.log("[Preview Helper] Test API transformation:", window.getApiUrl('/api/menu-styles')); 303 console.log("[Preview Helper] Test relative link transformation:", window.getPreviewUrl('/gallery')); 304 console.log("[Preview Helper] Test absolute URL transformation:", 305 new URL('/api/save-config', window.location.origin).toString().replace('/api/', '/' + previewGuid + '/api/')); 306 } 307 })(); 308 </script>
308 309 310 311 312 313<!-- Early CDN retry helpers: MUST run before CDN script/link tags. 314 Changing script.src after a load error is unreliable (esp. Firefox defer); 315 always insert a new element pointing at /cdn-proxy/. -->
316<script> 317(function() { 318 window.useCdnProxy = window.useCdnProxy || false; 319 window.__hydraCdnRetryLog = window.__hydraCdnRetryLog || []; 320 function toProxy(url) { 321 if (!url || url.indexOf('cdn.neonsky.app') === -1) return url; 322 return url.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/'); 323 } 324 function noteRetry(kind, fromUrl, toUrl) { 325 try { 326 window.useCdnProxy = true; 327 var entry = { kind: kind, from: fromUrl, to: toUrl, t: Date.now() }; 328 window.__hydraCdnRetryLog.push(entry); 329 console.warn('ð CDN failed, retrying via new element:', kind, fromUrl, '->', toUrl); 330 } catch (e) {} 331 } 332 window.__hydraRetryCdnScript = function(el) { 333 try { 334 if (!el || el.getAttribute('data-retried') === 'true') return; 335 var original = el.src || ''; 336 if (original.indexOf('cdn.neonsky.app') === -1) return; 337 el.setAttribute('data-retried', 'true'); 338 el.onerror = null; 339 var proxyUrl = toProxy(original); 340 noteRetry('script', original, proxyUrl); 341 var s = document.createElement('script'); 342 s.src = proxyUrl; 343 s.setAttribute('data-retried', 'true'); 344 if (el.id) s.id = el.id; 345 // Dynamic scripts ignore defer; omit it so the proxy copy executes on load. 346 if (el.parentNode) { 347 el.parentNode.insertBefore(s, el.nextSibling); 348 } else { 349 (document.head || document.documentElement).appendChild(s); 350 } 351 } catch (e) {} 352 }; 353 window.__hydraRetryCdnLink = function(el) { 354 try { 355 if (!el || el.getAttribute('data-retried') === 'true') return; 356 var original = el.href || ''; 357 if (original.indexOf('cdn.neonsky.app') === -1) return; 358 el.setAttribute('data-retried', 'true'); 359 el.onerror = null; 360 var proxyUrl = toProxy(original); 361 noteRetry('link', original, proxyUrl); 362 var link = document.createElement('link'); 363 link.rel = el.rel || 'stylesheet'; 364 if (el.id) link.id = el.id; 365 link.href = proxyUrl; 366 link.setAttribute('data-retried', 'true'); 367 if (el.parentNode) { 368 el.parentNode.insertBefore(link, el.nextSibling); 369 } else { 370 (document.head || document.documentElement).appendChild(link); 371 } 372 } catch (e) {} 373 }; 374})(); 375</script>
375 376<link rel="stylesheet" href="https://cdn.neonsky.app/menu-styles-v260923-002.css?t=1790866414373" onerror="window.__hydraRetryCdnLink(this)">
377<script src="https://assets.lemonsqueezy.com/lemon.js" defer></script>
vendor: 1 bytes, line 377
377
378<script src="https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js"></script>
vendor: 1 bytes, line 378
378
379<script src="https://cdn.jsdelivr.net/npm/magic-sdk"></script>
vendor: 1 bytes, line 379
379
380<script src="https://cdn.jsdelivr.net/npm/@magic-ext/auth"></script>
vendor: 1 bytes, line 380
380
381<script src="https://cdn.neonsky.app/style-customizer-v260923-002.js?t=1790866414373" defer onerror="window.__hydraRetryCdnScript(this)"></script>
381 382<link rel="stylesheet" href="https://cdn.neonsky.app/palette-explorer-v260923-002.css?t=1790866414373" onerror="window.__hydraRetryCdnLink(this)">
383<script src="https://cdn.neonsky.app/palette-explorer-v260923-002.js?t=1790866414373" defer onerror="window.__hydraRetryCdnScript(this)"></script>
vendor: 1 bytes, line 383
383
384<script src="https://cdn.neonsky.app/sidebar-manager-v260923-002.js?t=1790866414373" defer onerror="window.__hydraRetryCdnScript(this)"></script>
vendor: 1 bytes, line 384
384
385<script src="https://cdn.neonsky.app/image-processor-v260923-002.js?t=1790866414373" defer onerror="window.__hydraRetryCdnScript(this)"></script>
vendor: 1 bytes, line 385
385
386<script src="https://cdn.neonsky.app/image-uploader-v260923-002.js?t=1790866414373" defer onerror="window.__hydraRetryCdnScript(this)"></script>
vendor: 1 bytes, line 386
386
387<script src="https://cdn.neonsky.app/page-layout-presets-v260923-002.js?t=1790866414373" defer onerror="window.__hydraRetryCdnScript(this)"></script>
vendor: 1 bytes, line 387
387
388<script src="https://cdn.neonsky.app/site-graph-presets-v260923-002.js?t=1790866414373" defer onerror="window.__hydraRetryCdnScript(this)"></script>
vendor: 1 bytes, line 388
388
389<script src="https://cdn.neonsky.app/site-graph-sync-v260923-002.js?t=1790866414373" defer onerror="window.__hydraRetryCdnScript(this)"></script>
vendor: 1 bytes, line 389
389
390<script src="https://cdn.neonsky.app/page-manager-v260923-002.js?t=1790866414373" defer onerror="window.__hydraRetryCdnScript(this)"></script>
390 391<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css"> 392<link rel="stylesheet" href="https://cdn.neonsky.app/page-styles-v260923-002.css?t=1790866414373" onerror="window.__hydraRetryCdnLink(this)"> 393 394 395 396<!-- CDN Fallback Handler for CSS and JS files -->
397<script> 398(function() { 399 // Global flag: Set to true when any CDN resource fails and uses proxy 400 window.useCdnProxy = window.useCdnProxy || false; 401 402 // Global base URL function: Returns proxy base if flag is set, otherwise CDN base 403 window.getCdnBaseUrl = function() { 404 return window.useCdnProxy 405 ? window.location.origin + '/cdn-proxy/' 406 : 'https://cdn.neonsky.app/'; 407 }; 408 409 // Utility function to convert CDN URLs to proxy URLs 410 window.convertCdnToProxyUrl = function(cdnUrl) { 411 if (!cdnUrl || !cdnUrl.includes('cdn.neonsky.app')) return cdnUrl; 412 return cdnUrl.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/'); 413 }; 414 415 // Utility function to construct CDN URLs (uses proxy if flag is set) 416 window.buildCdnUrl = function(path) { 417 return window.getCdnBaseUrl() + path; 418 }; 419 420 // Resource tracking for debugging 421 window._resourceTracking = window._resourceTracking || { 422 resources: [], 423 proxyFlagChanges: [], 424 imageLoads: [], 425 networkErrors: [] 426 }; 427 428 // Track when proxy flag changes - use a wrapper function 429 let proxyFlagChangeCount = 0; 430 let _useCdnProxyValue = window.useCdnProxy || false; 431 432 // Create a setter function that tracks changes 433 window._setUseCdnProxy = function(newValue, reason) { 434 const oldValue = _useCdnProxyValue; 435 if (oldValue !== newValue) { 436 proxyFlagChangeCount++; 437 const changeInfo = { 438 timestamp: new Date().toISOString(), 439 changeNumber: proxyFlagChangeCount, 440 oldValue: oldValue, 441 newValue: newValue, 442 reason: reason || 'Unknown', 443 stack: new Error().stack?.split('\n').slice(1, 6).join('\n') || 'No stack' 444 }; 445 window._resourceTracking.proxyFlagChanges.push(changeInfo); 446 console.error('ð PROXY_FLAG_CHANGE:', JSON.stringify(changeInfo, null, 2)); 447 // Keep only last 50 changes 448 if (window._resourceTracking.proxyFlagChanges.length > 50) { 449 window._resourceTracking.proxyFlagChanges.shift(); 450 } 451 _useCdnProxyValue = newValue; 452 window.useCdnProxy = newValue; 453 } 454 }; 455 456 // Track resource loading 457 function trackResource(url, type, status, details) { 458 const entry = { 459 timestamp: new Date().toISOString(), 460 url: url, 461 type: type, // 'script', 'link', 'image', etc. 462 status: status, // 'loading', 'success', 'failed', 'proxy', 'storage' 463 details: details || {}, 464 useCdnProxy: window.useCdnProxy 465 }; 466 window._resourceTracking.resources.push(entry); 467 console.error('ð RESOURCE_TRACK:', JSON.stringify(entry, null, 2)); 468 // Keep only last 200 resources 469 if (window._resourceTracking.resources.length > 200) { 470 window._resourceTracking.resources.shift(); 471 } 472 } 473 474 // Track image loads 475 function trackImageLoad(url, status, source) { 476 const entry = { 477 timestamp: new Date().toISOString(), 478 url: url, 479 status: status, // 'loading', 'success', 'failed' 480 source: source, // 'cdn', 'proxy', 'storage' 481 useCdnProxy: window.useCdnProxy 482 }; 483 window._resourceTracking.imageLoads.push(entry); 484 console.error('ð¼ï¸ IMAGE_LOAD:', JSON.stringify(entry, null, 2)); 485 // Keep only last 100 image loads 486 if (window._resourceTracking.imageLoads.length > 100) { 487 window._resourceTracking.imageLoads.shift(); 488 } 489 } 490 491 // Track network errors 492 function trackNetworkError(url, error, context) { 493 const entry = { 494 timestamp: new Date().toISOString(), 495 url: url, 496 error: { 497 message: error?.message || 'Unknown error', 498 name: error?.name || 'Unknown', 499 type: error?.type || 'unknown', 500 toString: String(error) 501 }, 502 context: context || {}, 503 useCdnProxy: window.useCdnProxy 504 }; 505 window._resourceTracking.networkErrors.push(entry); 506 console.error('â NETWORK_ERROR:', JSON.stringify(entry, null, 2)); 507 // Keep only last 100 errors 508 if (window._resourceTracking.networkErrors.length > 100) { 509 window._resourceTracking.networkErrors.shift(); 510 } 511 } 512 513 // Expose tracking functions globally 514 window.trackResource = trackResource; 515 window.trackImageLoad = trackImageLoad; 516 window.trackNetworkError = trackNetworkError; 517 518 // Function to retry failed resources via proxy 519 // For ISP-affected clients, we assume CDN failures are network/SSL errors and set the proxy flag 520 // The proxy can handle both network errors and 404s, so it's safer to err on the side of using it 521 function retryViaProxy(element, originalSrc, errorEvent) { 522 if (!originalSrc) originalSrc = element.href || element.src; 523 if (!originalSrc || !originalSrc.includes('cdn.neonsky.app')) return; 524 525 // Prevent multiple retries 526 if (element.getAttribute('data-retried')) { 527 return; 528 } 529 element.setAttribute('data-retried', 'true'); 530 531 // Check if element still exists in DOM 532 if (!element.parentNode) { 533 console.warn('â ï¸ Element no longer in DOM, skipping proxy retry:', originalSrc); 534 return; 535 } 536 537 // For ISP-affected clients, CDN failures are almost always network/SSL errors, not 404s 538 // We'll be aggressive and set the flag for any CDN failure to ensure images also use proxy 539 // Check error message if available, but default to assuming network error 540 const hasExplicitNetworkError = errorEvent && errorEvent.message && ( 541 errorEvent.message.includes('Failed to fetch') || 542 errorEvent.message.includes('SSL') || 543 errorEvent.message.includes('secure connection') || 544 errorEvent.message.includes('network') || 545 errorEvent.message.includes('ERR_') ||
546 errorEvent.message.includes('SSL_ERROR') 547 ); 548 549 // Track the error 550 trackNetworkError(originalSrc, errorEvent, { 551 elementType: element.tagName, 552 hasExplicitNetworkError: hasExplicitNetworkError, 553 errorMessage: errorEvent?.message || 'No error message', 554 errorName: errorEvent?.name || 'No error name' 555 }); 556 557 // Set flag if we have explicit network error, or if errorEvent is missing/null (browser silent failure) 558 // This covers ISP-affected clients where SSL handshake fails before HTTP request 559 const reason = hasExplicitNetworkError 560 ? 'Explicit network/SSL error detected' 561 : (!errorEvent || !errorEvent.message) 562 ? 'Browser silent failure (likely SSL/network)' 563 : 'CDN resource failed (using proxy as fallback)'; 564 565 if (hasExplicitNetworkError || !errorEvent || !errorEvent.message) { 566 window._setUseCdnProxy(true, reason); 567 console.error('ð© CDN_PROXY_FLAG SET: CDN failure detected, using proxy for all subsequent requests'); 568 console.error(' Error details:', errorEvent ? (errorEvent.message || 'No error message (likely SSL/network failure)') : 'No error event'); 569 } else { 570 // Even for other errors, set the flag to be safe - proxy can handle 404s too 571 window._setUseCdnProxy(true, reason); 572 console.error('ð© CDN_PROXY_FLAG SET: CDN resource failed, using proxy as fallback'); 573 } 574 575 const proxyUrl = originalSrc.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/'); 576 console.error('ð CDN failed, retrying via proxy:', originalSrc, '->', proxyUrl); 577 578 // Track the retry 579 trackResource(originalSrc, element.tagName.toLowerCase(), 'failed', { 580 retryUrl: proxyUrl, 581 error: errorEvent?.message || 'Unknown' 582 }); 583 584 if (element.tagName === 'SCRIPT') { 585 const newScript = document.createElement('script'); 586 newScript.src = proxyUrl; 587 // Do not copy defer/async onto dynamically inserted scripts. After a CDN 588 // failure, DOMContentLoaded may already have fired; deferred dynamic 589 // scripts can fail to execute (seen on Firefox ISP/CDN-fail clients). 590 newScript.setAttribute('data-retried', 'true'); 591 newScript.onload = function() { 592 trackResource(proxyUrl, 'script', 'proxy-success', { originalUrl: originalSrc }); 593 }; 594 newScript.onerror = function() { 595 trackResource(proxyUrl, 'script', 'proxy-failed', { originalUrl: originalSrc }); 596 console.error('â Proxy also failed, trying storage:', proxyUrl); 597 const storageUrl = originalSrc.replace('cdn.neonsky.app', 'storage.neonsky.app'); 598 trackResource(storageUrl, 'script', 'storage-fallback', { originalUrl: originalSrc, proxyUrl: proxyUrl }); 599 newScript.src = storageUrl; 600 newScript.onload = function() { 601 trackResource(storageUrl, 'script', 'storage-success', { originalUrl: originalSrc }); 602 }; 603 }; 604 element.parentNode.replaceChild(newScript, element); 605 } else if (element.tagName === 'LINK') { 606 const newLink = document.createElement('link'); 607 newLink.rel = element.rel; 608 newLink.href = proxyUrl; 609 newLink.setAttribute('data-retried', 'true'); 610 newLink.onload = function() { 611 trackResource(proxyUrl, 'link', 'proxy-success', { originalUrl: originalSrc }); 612 }; 613 newLink.onerror = function() { 614 trackResource(proxyUrl, 'link', 'proxy-failed', { originalUrl: originalSrc }); 615 console.error('â Proxy also failed, trying storage:', proxyUrl); 616 const storageUrl = originalSrc.replace('cdn.neonsky.app', 'storage.neonsky.app'); 617 trackResource(storageUrl, 'link', 'storage-fallback', { originalUrl: originalSrc, proxyUrl: proxyUrl }); 618 newLink.href = storageUrl; 619 newLink.onload = function() { 620 trackResource(storageUrl, 'link', 'storage-success', { originalUrl: originalSrc }); 621 }; 622 }; 623 element.parentNode.replaceChild(newLink, element); 624 } 625 } 626 627 // Monitor script and link tags for errors 628 document.addEventListener('error', function(e) { 629 const target = e.target; 630 if ((target.tagName === 'SCRIPT' || target.tagName === 'LINK') && 631 target.src && target.src.includes('cdn.neonsky.app')) { 632 retryViaProxy(target, target.src, e); 633 } else if (target.tagName === 'LINK' && target.href && target.href.includes('cdn.neonsky.app')) { 634 retryViaProxy(target, target.href, e); 635 } 636 }, true); 637 638 // Also check existing scripts/links that may have failed before this script loaded 639 // For ISP-affected clients, we assume CDN failures are network/SSL errors 640 setTimeout(function() { 641 document.querySelectorAll('script[src*="cdn.neonsky.app"], link[href*="cdn.neonsky.app"]').forEach(function(el) { 642 if (el.tagName === 'SCRIPT' && !el.getAttribute('data-loaded') && !el.getAttribute('data-retried')) { 643 el.addEventListener('error', function(errorEvent) { 644 // For ISP-affected clients, assume any CDN failure is a network/SSL error 645 // The proxy can handle both network errors and 404s 646 retryViaProxy(el, el.src, errorEvent); 647 }); 648 } else if (el.tagName === 'LINK' && !el.sheet && !el.getAttribute('data-loaded') && !el.getAttribute('data-retried')) { 649 // CSS link failed if it has no stylesheet - check after a delay 650 setTimeout(function() { 651 if (!el.sheet && !el.getAttribute('data-retried')) { 652 // For ISP-affected clients, assume any CDN failure is a network/SSL error 653 retryViaProxy(el, el.href, null); 654 } 655 }, 2000); 656 } 657 }); 658 }, 100); 659})(); 660</script>
660 661 662<!-- Log Capture System - S+J to download logs (works even if gallery doesn't load) -->
663<script> 664(function() { 665 'use strict'; 666 667 // Only initialize if not already initialized 668 if (window._indexLogCaptureInitialized) { 669 return; 670 } 671 window._indexLogCaptureInitialized = true; 672 673 // Wait for DOM to be ready 674 function initLogCapture() { 675 // Store original console methods 676 const originalConsole = { 677 log: console.log, 678 warn: console.warn, 679 error: console.error, 680 info: console.info 681 }; 682 683 // Array to store all console messages 684 window._capturedLogs = window._capturedLogs || []; 685 686 // Override console methods to capture logs 687 ['log', 'warn', 'error', 'info'].forEach(method => { 688 console[method] = function(...args) { 689 // Call original method 690 originalConsole[method].apply(console, args); 691 692 // Capture the log with timestamp 693 window._capturedLogs.push({ 694 timestamp: new Date().toISOString(), 695 level: method.toUpperCase(), 696 message: args.map(function(arg) { 697 if (arg !== null && arg !== undefined && Object(arg) === arg) { 698 try { 699 return JSON.stringify(arg, null, 2); 700 } catch (e) { 701 return String(arg); 702 } 703 } 704 return String(arg); 705 }).join(' ') 706 }); 707 708 // Keep only last 2000 logs to prevent memory issues 709 if (window._capturedLogs.length > 2000) { 710 window._capturedLogs.shift(); 711 } 712 }; 713 }); 714 715 // Add keyboard shortcut to download logs (S+J) 716 let pressedKeys = new Set(); 717 718 document.addEventListener('keydown', function(event) { 719 const key = (event.key || '').toLowerCase(); 720 721 if (key === 's' || key === 'j') { 722 pressedKeys.add(key); 723 724 // Check if both S and J are pressed 725 if (pressedKeys.has('s') && pressedKeys.has('j')) { 726 event.preventDefault(); 727 console.log('S+J detected - downloading logs...'); 728 729 // Show a brief visual indicator 730 const indicator = document.createElement('div'); 731 indicator.style.cssText = 732 'position: fixed; top: 20px; right: 20px; z-index: 10000; ' + 733 'background: #4CAF50; color: white; padding: 10px 20px; ' + 734 'border-radius: 5px; font-family: Arial, sans-serif; ' + 735 'font-size: 14px; font-weight: bold; ' + 736 'box-shadow: 0 2px 10px rgba(0,0,0,0.3);'; 737 indicator.textContent = 'ð¥ Downloading logs...'; 738 document.body.appendChild(indicator); 739 740 setTimeout(() => { 741 if (indicator.parentNode) { 742 indicator.parentNode.removeChild(indicator); 743 } 744 }, 2000); 745 746 downloadLogs(); 747 // Clear the set after triggering 748 pressedKeys.clear(); 749 } 750 } 751 }); 752 753 document.addEventListener('keyup', function(event) { 754 const key = (event.key || '').toLowerCase(); 755 if (key === 's' || key === 'j') { 756 pressedKeys.delete(key); 757 } 758 }); 759 760 console.log('ð Log capture system initialized. Press S+J simultaneously to download logs.'); 761 } 762 763 // Download captured logs as a text file 764 function downloadLogs() { 765 try { 766 // Get all scripts and links, categorized 767 const allScripts = Array.from(document.querySelectorAll('script[src]')).map(s => s.src); 768 const allLinks = Array.from(document.querySelectorAll('link[href]')).map(l => l.href); 769 770 // Get additional debug info 771 const debugInfo = { 772 url: window.location.href, 773 userAgent: navigator.userAgent, 774 timestamp: new Date().toISOString(), 775 useCdnProxy: window.useCdnProxy || false, 776 windowLocation: window.location.toString(), 777 documentReadyState: document.readyState, 778 // All scripts/links (not just CDN) 779 allScriptsLoaded: allScripts, 780 allLinksLoaded: allLinks, 781 // CDN-specific (for backward compatibility and CDN debugging) 782 scriptsLoaded: allScripts.filter(src => src.includes('cdn.neonsky.app')), 783 linksLoaded: allLinks.filter(href => href.includes('cdn.neonsky.app')), 784 // Gallery scripts (loaded from Worker) 785 galleryScriptsLoaded: allScripts.filter(src => src.includes('/gallery-scripts/')), 786 galleryLinksLoaded: allLinks.filter(href => href.includes('/gallery-scripts/')), 787 // Failed resources 788 failedScripts: Array.from(document.querySelectorAll('script[src*="cdn.neonsky.app"][data-retried]')).map(s => s.src), 789 failedLinks: Array.from(document.querySelectorAll('link[href*="cdn.neonsky.app"][data-retried]')).map(l => l.href), 790 // Proxy resources 791 proxyScriptsLoaded: allScripts.filter(src => src.includes('/cdn-proxy/')), 792 proxyLinksLoaded: allLinks.filter(href => href.includes('/cdn-proxy/')), 793 // Resource tracking data 794 resourceTracking: window._resourceTracking || { 795 resources: [], 796 proxyFlagChanges: [], 797 imageLoads: [], 798 networkErrors: [] 799 }, 800 // Performance timing 801 performanceTiming: performance.timing ? { 802 navigationStart: performance.timing.navigationStart, 803 domContentLoaded: performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart, 804 loadComplete: performance.timing.loadEventEnd - performance.timing.navigationStart, 805 domInteractive: performance.timing.domInteractive - performance.timing.navigationStart 806 } : null, 807 // Style / logo diagnostics (layout/color bug triage) 808 styleDiagnostics: (function() { 809 try { 810 var rootStyle = getComputedStyle(document.documentElement); 811 var logoNodes = Array.from(document.querySelectorAll('.sidebar img, .sidebar-element img, img[src*="robert-vizzini"], .horizontal-header-logo img')); 812 return {
813 siteBackgroundColor: rootStyle.getPropertyValue('--site-background-color').trim(), 814 menuBackgroundColor: rootStyle.getPropertyValue('--menu-background-color').trim(), 815 menuColor: rootStyle.getPropertyValue('--menu-color').trim(), 816 inlineSiteBg: document.documentElement.style.getPropertyValue('--site-background-color'), 817 inlineMenuBg: document.documentElement.style.getPropertyValue('--menu-background-color'), 818 hasMenuStyleCustomizer: !!window.MenuStyleCustomizer, 819 menuStyleCustomizerInitialized: !!(window.MenuStyleCustomizer && window.MenuStyleCustomizer._initialized), 820 menuStyleSettingsBg: window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings 821 ? { 822 siteBackgroundColor: window.MenuStyleCustomizer.settings.siteBackgroundColor, 823 menuBackgroundColor: window.MenuStyleCustomizer.settings.menuBackgroundColor, 824 color: window.MenuStyleCustomizer.settings.color 825 } 826 : null, 827 hasSidebarManager: !!window.SidebarManager, 828 sidebarElementCount: window.SidebarManager && window.SidebarManager.elements 829 ? window.SidebarManager.elements.length 830 : 0, 831 hasPageManager: !!window.PageManager, 832 pageManagerHasLoadPage: !!(window.PageManager && typeof window.PageManager.loadPage === 'function'), 833 pageElementCount: (function() { 834 try { 835 return document.querySelectorAll('.gallery-container .page-element, .page-container .page-element').length; 836 } catch (e) { return -1; } 837 })(), 838 pageContainerHtmlLength: (function() { 839 try { 840 var pc = document.querySelector('.gallery-container .page-container, .page-container'); 841 return pc ? (pc.innerHTML || '').length : 0; 842 } catch (e) { return -1; } 843 })(), 844 activeContentType: window._activeContentType || null, 845 logoImageCount: logoNodes.length, 846 logoImages: logoNodes.slice(0, 5).map(function(img) { 847 return { 848 src: img.currentSrc || img.src || '', 849 naturalWidth: img.naturalWidth || 0, 850 display: getComputedStyle(img).display, 851 complete: !!img.complete 852 }; 853 }), 854 earlyCdnRetries: window.__hydraCdnRetryLog || [] 855 }; 856 } catch (e) { 857 return { error: String(e && e.message ? e.message : e) }; 858 } 859 })() 860 }; 861 862 // Create log content 863 const logContent = [ 864 '=== INDEX.TS DEBUG LOGS ===', 865 'Generated: ' + debugInfo.timestamp, 866 'URL: ' + debugInfo.url, 867 'User Agent: ' + debugInfo.userAgent, 868 'useCdnProxy Flag: ' + debugInfo.useCdnProxy, 869 'Document Ready State: ' + debugInfo.documentReadyState, 870 '================================', 871 'TIP: Search for [AUTH_SAVE] in CONSOLE LOGS below for authentication/save failure details.', 872 'TIP: Search STYLE DIAGNOSTICS for layout/color/logo triage (backgrounds, MenuStyleCustomizer, SidebarManager, logo).', 873 '================================', 874 '', 875 '=== DEBUG INFO ===', 876 JSON.stringify(debugInfo, null, 2), 877 '', 878 '=== STYLE DIAGNOSTICS ===', 879 JSON.stringify(debugInfo.styleDiagnostics, null, 2), 880 '', 881 '=== RESOURCE TRACKING SUMMARY ===', 882 'Total Resources Tracked: ' + (debugInfo.resourceTracking.resources.length || 0), 883 'Proxy Flag Changes: ' + (debugInfo.resourceTracking.proxyFlagChanges.length || 0), 884 'Image Loads Tracked: ' + (debugInfo.resourceTracking.imageLoads.length || 0), 885 'Network Errors Tracked: ' + (debugInfo.resourceTracking.networkErrors.length || 0), 886 '', 887 '=== PROXY FLAG CHANGES ===', 888 JSON.stringify(debugInfo.resourceTracking.proxyFlagChanges, null, 2), 889 '', 890 '=== RESOURCE LOADING TRACKING ===', 891 JSON.stringify(debugInfo.resourceTracking.resources, null, 2), 892 '', 893 '=== IMAGE LOADING TRACKING ===', 894 JSON.stringify(debugInfo.resourceTracking.imageLoads, null, 2), 895 '', 896 '=== NETWORK ERRORS ===', 897 JSON.stringify(debugInfo.resourceTracking.networkErrors, null, 2), 898 '', 899 '=== CONSOLE LOGS ===', 900 ...(window._capturedLogs || []).map(log => 901 '[' + log.timestamp + '] ' + log.level + ': ' + log.message 902 ) 903 ].join('\n'); 904 905 // Create and download file 906 const blob = new Blob([logContent], { type: 'text/plain' }); 907 const url = URL.createObjectURL(blob); 908 const a = document.createElement('a'); 909 a.href = url; 910 a.download = 'index-debug-logs-' + new Date().toISOString().replace(/[:.]/g, '-') + '.txt';
911 document.body.appendChild(a); 912 a.click(); 913 document.body.removeChild(a); 914 URL.revokeObjectURL(url); 915 916 console.log('â Debug logs downloaded successfully!'); 917 } catch (error) { 918 console.error('â Failed to download logs:', error); 919 alert('Failed to download logs. Error: ' + (error.message || 'Unknown error')); 920 } 921 } 922 923 // Initialize when DOM is ready 924 if (document.readyState === 'loading') { 925 document.addEventListener('DOMContentLoaded', initLogCapture); 926 } else { 927 // DOM already ready, initialize immediately 928 setTimeout(initLogCapture, 100); 929 } 930})(); 931</script>
931 932 933<!-- Add auth token if available --> 934 935 936<style> 937/* Override Pico.css body padding to prevent jumping between pages */ 938body { 939 padding: 0 !important; 940 margin: 0 !important; 941 display: block !important; 942} 943 944/* Scope Pico.css to only affect elements in edit mode */ 945:root:not([data-theme="pico"]) { 946--pico-applied: dark; 947} 948 949/* Add data-theme="pico" to body when in edit mode */ 950body.edit-mode-active { 951data-theme: dark; 952} 953 954/* Custom styles to ensure Pico only affects editor UI, not content */ 955.sidebar.editing button, 956.edit-controls button, 957.element-header button, 958.text-editor button, 959.image-uploader button { 960--pico-applied: dark; 961} 962 963/* Hide default browser-styling when Pico is active */ 964[data-theme="pico"] button.btn { 965appearance: dark; 966-webkit-appearance: dark; 967} 968 969/* Ensure content isn't affected by Pico styles */ 970.gallery-container, 971.page-text, 972.sidebar-text { 973--pico-applied: none !important; 974} 975 976/* Conflict Indicator Styles */ 977#conflictIndicator { 978 background: #d32f2f !important; 979 border: 1px solid #d32f2f !important; 980 border-radius: 4px; 981 padding: 8px; 982 cursor: pointer; 983 transition: opacity 0.2s; 984 margin-left: 8px; 985 display: flex; /* Use flex for centering, but hidden by default via inline style */ 986 align-items: center; 987 justify-content: center; 988} 989 990#conflictIndicator:hover { 991 background: #e53935 !important; 992 border-color: #e53935 !important; 993 opacity: 0.9; 994} 995 996#conflictIndicator svg { 997 display: block; 998 width: 24px; 999 height: 24px; 1000 margin: 0 auto; 1001 color: white !important; 1002} 1003 1004/* Tooltip styles for conflict indicator - allow wrapping */ 1005#tooltip-container .tooltip[data-tooltip-for="conflictIndicator"], 1006#tooltip-container .tooltip { 1007 max-width: 200px; 1008 word-wrap: break-word; 1009 white-space: normal; 1010 line-height: 1.4; 1011} 1012 1013.global-header-controls { 1014 display: flex; 1015 align-items: center; 1016 gap: 8px; 1017} 1018 1019.sidebar-header { 1020 display: flex; 1021 align-items: center; 1022 gap: 8px; 1023} 1024 1025/* Cookie Notice â paper UI (public-safe; does not depend on edit-mode pe-* scopes) */ 1026.cookie-notice { 1027 --cn-paper: #f6f5f1; 1028 --cn-paper-elevated: #fdfcfa; 1029 --cn-ink: #2c2a27; 1030 --cn-ink-muted: #6b6760; 1031 --cn-ink-faint: #9b968d; 1032 --cn-rule: rgba(44, 42, 39, 0.1); 1033 --cn-rule-strong: rgba(44, 42, 39, 0.16); 1034 --cn-shadow: 0 -8px 28px rgba(44, 42, 39, 0.1); 1035 --cn-radius: 3px; 1036 --cn-font: "DM Sans", system-ui, -apple-system, sans-serif; 1037 1038 position: fixed; 1039 bottom: 0; 1040 left: 0; 1041 right: 0; 1042 z-index: 10000; 1043 display: flex; 1044 align-items: center; 1045 justify-content: space-between; 1046 flex-wrap: wrap; 1047 gap: 0.85rem 1.25rem; 1048 padding: 1.1rem 1.35rem 1.2rem; 1049 font-family: var(--cn-font); 1050 font-size: 14px; 1051 line-height: 1.45; 1052 color: var(--cn-ink); 1053 background: var(--cn-paper-elevated); 1054 border-top: 1px solid var(--cn-rule); 1055 box-shadow: var(--cn-shadow); 1056} 1057 1058.cookie-notice.hidden { 1059 display: none; 1060} 1061 1062.cookie-notice-text { 1063 flex: 1 1 220px; 1064 min-width: 0; 1065 margin: 0; 1066 color: var(--cn-ink-muted); 1067 font-size: 13px; 1068} 1069 1070.cookie-notice-text a.cookie-notice-policy { 1071 color: var(--cn-ink); 1072 font-weight: 600; 1073 text-decoration: underline; 1074 text-underline-offset: 2px; 1075} 1076 1077.cookie-notice-text a.cookie-notice-policy:hover { 1078 color: var(--cn-ink-muted); 1079} 1080 1081.cookie-notice-buttons { 1082 display: flex; 1083 flex-wrap: wrap; 1084 align-items: center; 1085 justify-content: flex-end; 1086 gap: 0.5rem; 1087} 1088 1089.cookie-notice .pe-btn { 1090 display: inline-flex; 1091 align-items: center; 1092 justify-content: center; 1093 gap: 0.4rem; 1094 margin: 0; 1095 padding: 0.45rem 0.85rem; 1096 font-family: var(--cn-font); 1097 font-size: 11px; 1098 font-weight: 600; 1099 letter-spacing: 0.1em; 1100 text-transform: uppercase; 1101 color: var(--cn-ink-muted); 1102 background: var(--cn-paper); 1103 border: 1px solid var(--cn-rule-strong); 1104 border-radius: var(--cn-radius); 1105 box-shadow: none; 1106 cursor: pointer; 1107 transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; 1108} 1109 1110.cookie-notice .pe-btn:hover { 1111 color: var(--cn-ink); 1112 background: var(--cn-paper-elevated); 1113 border-color: rgba(44, 42, 39, 0.22); 1114} 1115 1116.cookie-notice .pe-btn--primary { 1117 color: var(--cn-ink); 1118 background: var(--cn-paper); 1119 border-color: var(--cn-rule-strong); 1120} 1121 1122.cookie-notice .pe-btn--primary:hover {
1123 color: var(--cn-ink); 1124 background: var(--cn-paper-elevated); 1125 border-color: rgba(44, 42, 39, 0.22); 1126} 1127 1128#hydraDoNotSellLink { 1129 --cn-paper: #f6f5f1; 1130 --cn-paper-elevated: #fdfcfa; 1131 --cn-ink: #2c2a27; 1132 --cn-ink-muted: #6b6760; 1133 --cn-rule-strong: rgba(44, 42, 39, 0.16); 1134 --cn-radius: 3px; 1135 --cn-font: "DM Sans", system-ui, -apple-system, sans-serif; 1136 1137 position: fixed; 1138 bottom: 10px; 1139 left: 12px; 1140 z-index: 9999; 1141 display: inline-flex; 1142 align-items: center; 1143 justify-content: center; 1144 margin: 0; 1145 padding: 0.45rem 0.85rem; 1146 font-family: var(--cn-font); 1147 font-size: 11px; 1148 font-weight: 600; 1149 letter-spacing: 0.1em; 1150 text-transform: uppercase; 1151 color: var(--cn-ink-muted); 1152 background: var(--cn-paper-elevated); 1153 border: 1px solid var(--cn-rule-strong); 1154 border-radius: var(--cn-radius); 1155 box-shadow: 0 2px 8px rgba(44, 42, 39, 0.06); 1156 cursor: pointer; 1157} 1158 1159#hydraDoNotSellLink.hidden { 1160 display: none; 1161} 1162 1163#hydraDoNotSellLink:hover { 1164 color: var(--cn-ink); 1165 background: var(--cn-paper); 1166 border-color: rgba(44, 42, 39, 0.22); 1167} 1168 1169/* FOUC reveal (paired with early <head> html:not(.hydra-ready) { opacity: 0 }) */ 1170html.hydra-ready { 1171 opacity: 1; 1172 transition: opacity 0.2s ease; 1173} 1174/* Keep body class for any legacy selectors; visibility no longer used (submenu CSS pierces it) */ 1175body.hydra-ready { 1176 visibility: visible; 1177} 1178 1179/* Admin panel typography */ 1180.sidebar-element-form, 1181.sidebar-element-form *, 1182.add-form, 1183.add-form *, 1184.metadata-editor, 1185.metadata-editor * { 1186 font-family: "Hanken Grotesk", sans-serif; 1187 font-weight: 200; 1188 letter-spacing: 0.02em; 1189} 1190 1191.sidebar-element-form h3, 1192.sidebar-element-form label, 1193.add-form label, 1194.metadata-editor h3, 1195.metadata-editor label { 1196 text-transform: uppercase; 1197 letter-spacing: 0.04em; 1198 font-weight: 300; 1199} 1200 1201.sidebar-element-form .form-actions .btn, 1202.sidebar-element-form button, 1203.add-form .form-actions .btn, 1204.add-form button, 1205.metadata-editor .form-actions .btn, 1206.metadata-editor button { 1207 text-transform: uppercase; 1208 letter-spacing: 0.08em; 1209 font-weight: 300; 1210} 1211 1212.sidebar-element-form input, 1213.sidebar-element-form select, 1214.sidebar-element-form textarea, 1215.sidebar-element-form button, 1216.add-form input, 1217.add-form select, 1218.add-form textarea, 1219.add-form button, 1220.metadata-editor input, 1221.metadata-editor select, 1222.metadata-editor textarea, 1223.metadata-editor button { 1224 font-family: "Hanken Grotesk", sans-serif; 1225 font-weight: 200; 1226 letter-spacing: 0.02em; 1227 border-radius: 0; 1228} 1229</style> 1230
1231<script> 1232// Flag to prevent duplicate initialization 1233window.hydraInitialized = false; 1234 1235function ensureAdminPanelFont() { 1236 const head = document.head || document.getElementsByTagName('head')[0]; 1237 if (!head) { 1238 return; 1239 } 1240 1241 const linkDefinitions = [ 1242 { 1243 id: 'admin-panels-font-preconnect-googleapis', 1244 rel: 'preconnect', 1245 href: 'https://fonts.googleapis.com' 1246 }, 1247 { 1248 id: 'admin-panels-font-preconnect-gstatic', 1249 rel: 'preconnect', 1250 href: 'https://fonts.gstatic.com', 1251 crossOrigin: 'anonymous' 1252 }, 1253 { 1254 id: 'admin-panels-font-stylesheet', 1255 rel: 'stylesheet', 1256 href: 'https://fonts.googleapis.com/css2?family=Hanken+Grotesk:ital,wght@0,200;1,200&display=swap' 1257 }, 1258 { 1259 id: 'admin-panels-font-barlow', 1260 rel: 'stylesheet', 1261 href: 'https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,200;0,400;1,200;1,400&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap' 1262 } 1263 ]; 1264 1265 for (const definition of linkDefinitions) { 1266 if (!document.getElementById(definition.id)) { 1267 const link = document.createElement('link'); 1268 link.id = definition.id; 1269 link.rel = definition.rel; 1270 link.href = definition.href; 1271 1272 if (definition.crossOrigin) { 1273 link.crossOrigin = definition.crossOrigin; 1274 } 1275 1276 head.appendChild(link); 1277 } 1278 } 1279} 1280 1281ensureAdminPanelFont(); 1282 1283 window.Parameters = window.Parameters || {}; 1284 window.Parameters.siteId = "btfkih7o"; 1285 window.Parameters.SiteId = "btfkih7o"; // Also set capitalized version 1286 window.siteId = "btfkih7o"; 1287 window.TURNSTILE_SITE_KEY = "0x4AAAAAADyHXlv6RTUIaDOp"; 1288 window.NEON_SKY_FILES = window.NEON_SKY_FILES || {}; 1289 window.NEON_SKY_FILES.QUILL_INTEGRATION = 'quill-integration-v260923-002.js'; 1290 window.NEON_SKY_FILES.SITE_THEMES = 'site-themes-v260923-002.css'; 1291 window.NEON_SKY_FILES.SITE_THEMES_MANIFEST = 'site-themes-v260923-002.json'; 1292 1293// Initialize menu styles from server 1294document.addEventListener('DOMContentLoaded', function() { 1295// Apply initial menu styles from server 1296const initialMenuStyles = {"fontFamily":"Helvetica, sans-serif","fontSize":"12px","color":"#757575","activeColor":"#404040","hoverColor":"#000000","verticalSpacing":"2px","menuBackgroundColor":"#ffffff","siteBackgroundColor":"#ffffff","mobileHeaderHeight":"60px","sidebarWidth":"15vw","sidebarPaddingTop":"0px","sidebarPaddingRight":"0px","sidebarPaddingBottom":"0px","sidebarPaddingLeft":"0px","menuLayout":"sidebar","fontName":"Helvetica"}; 1297window.__hydraStyleFromQuery = null; 1298const initialSiteThemeRuntimeVars = null; 1299 1300// Set CSS custom properties 1301// ?hydra-style= preview: apply merged menuStyles (skip site-theme runtime vars that would override). 1302if (initialSiteThemeRuntimeVars && !window.__hydraStyleFromQuery) { 1303 Object.keys(initialSiteThemeRuntimeVars).forEach(function(key) { 1304 document.documentElement.style.setProperty(key, initialSiteThemeRuntimeVars[key]); 1305 }); 1306 if (initialMenuStyles && initialMenuStyles.verticalSpacing) { 1307 document.documentElement.style.setProperty('--menu-vertical-spacing', initialMenuStyles.verticalSpacing); 1308 } 1309 // Match menu chrome / Gallery Options overview preview (light weight) 1310 document.documentElement.style.setProperty('--menu-font-weight', '200'); 1311 if (initialMenuStyles && typeof initialMenuStyles.secondaryFontSize === 'string' && initialMenuStyles.secondaryFontSize.trim() !== '') { 1312 document.documentElement.style.setProperty('--gallery-description-font-size', initialMenuStyles.secondaryFontSize.trim()); 1313 } 1314} else if (initialMenuStyles) { 1315 if (initialMenuStyles.fontFamily) { 1316 document.documentElement.style.setProperty('--menu-font-family', initialMenuStyles.fontFamily); 1317 } 1318 if (initialMenuStyles.fontSize) { 1319 document.documentElement.style.setProperty('--menu-font-size', initialMenuStyles.fontSize); 1320 } 1321 // Only set when Secondary was explicitly saved; otherwise descriptions follow --menu-font-size 1322 if (typeof initialMenuStyles.secondaryFontSize === 'string' && initialMenuStyles.secondaryFontSize.trim() !== '') { 1323 document.documentElement.style.setProperty('--gallery-description-font-size', initialMenuStyles.secondaryFontSize.trim()); 1324 }
1325 document.documentElement.style.setProperty('--menu-font-weight', '200'); 1326 if (initialMenuStyles.color) { 1327 document.documentElement.style.setProperty('--menu-color', initialMenuStyles.color); 1328 } 1329 if (initialMenuStyles.activeColor) { 1330 document.documentElement.style.setProperty('--menu-active-color', initialMenuStyles.activeColor); 1331 } 1332 if (initialMenuStyles.hoverColor) { 1333 document.documentElement.style.setProperty('--menu-hover-color', initialMenuStyles.hoverColor); 1334 } 1335 // Background colors must be applied here too: MenuStyleCustomizer may load late/fail 1336 // after CDNâproxy retry, and CSS defaults are white/#fafafa. 1337 if (initialMenuStyles.siteBackgroundColor) { 1338 document.documentElement.style.setProperty('--site-background-color', initialMenuStyles.siteBackgroundColor); 1339 } 1340 if (initialMenuStyles.menuBackgroundColor && !initialMenuStyles.menuBackgroundHidden) { 1341 document.documentElement.style.setProperty('--menu-background-color', initialMenuStyles.menuBackgroundColor); 1342 } 1343 if (initialMenuStyles.verticalSpacing) { 1344 document.documentElement.style.setProperty('--menu-vertical-spacing', initialMenuStyles.verticalSpacing); 1345 } 1346 if (initialMenuStyles.menuBackgroundImageUrl && String(initialMenuStyles.menuBackgroundImageUrl).trim() !== '') { 1347 var menuBgImg = String(initialMenuStyles.menuBackgroundImageUrl).trim(); 1348 document.documentElement.style.setProperty('--menu-background-image', 'url(' + JSON.stringify(menuBgImg) + ')'); 1349 } 1350 var resolvedSiteBg = ''; 1351 if (window.SiteGradientGenerator && typeof window.SiteGradientGenerator.resolveSiteBackgroundImage === 'function') { 1352 resolvedSiteBg = window.SiteGradientGenerator.resolveSiteBackgroundImage(initialMenuStyles, { allowRandomize: true }); 1353 } else if (initialMenuStyles.siteBackgroundImageUrl) { 1354 resolvedSiteBg = String(initialMenuStyles.siteBackgroundImageUrl).trim(); 1355 } 1356 if (resolvedSiteBg) { 1357 var peIsCssGradient = String(resolvedSiteBg).toLowerCase().indexOf('gradient(') !== -1; 1358 var siteBgImgCss = peIsCssGradient 1359 ? resolvedSiteBg 1360 : 'url(' + JSON.stringify(resolvedSiteBg) + ')'; 1361 document.documentElement.style.setProperty('--site-background-image', siteBgImgCss); 1362 var siteBgSize = 'cover'; 1363 if (peIsCssGradient) { 1364 siteBgSize = window.SiteGradientGenerator && window.SiteGradientGenerator.gradientBackgroundSize 1365 ? window.SiteGradientGenerator.gradientBackgroundSize(resolvedSiteBg) 1366 : '100% 100%'; 1367 document.body.classList.add('has-site-background-gradient'); 1368 } 1369 document.documentElement.style.setProperty('--site-background-size', siteBgSize); 1370 } 1371 if (initialMenuStyles.mobileHeaderHeight && String(initialMenuStyles.mobileHeaderHeight).trim() !== '') { 1372 var mhh0 = String(initialMenuStyles.mobileHeaderHeight).trim(); 1373 /* 0 / 0vh / 0px collapse logo max-height calcs; use the same floor as getUserHorizontalHeaderHeightPx */ 1374 var mhhParsed = parseInt(mhh0, 10); 1375 if (Number.isNaN(mhhParsed) || mhhParsed < 1) { 1376 mhh0 = '60px'; 1377 } 1378 document.documentElement.style.setProperty('--mobile-header-height', mhh0); 1379 document.documentElement.style.setProperty('--content-margin-top', mhh0); 1380 document.documentElement.style.setProperty('--top-bar-height-setting', mhh0); 1381 } 1382} 1383 1384if (initialMenuStyles && initialMenuStyles.menuBackgroundHidden) { 1385 document.body.classList.add('menu-background-hidden'); 1386 document.documentElement.style.setProperty('--menu-background-color', 'transparent'); 1387 document.documentElement.style.setProperty('--menu-background-image', 'none'); 1388} 1389 1390if (initialMenuStyles && initialMenuStyles.menuTextUppercase) { 1391 document.body.classList.add('menu-text-uppercase'); 1392 if (!document.getElementById('menu-text-uppercase-style')) { 1393 var menuUpperStyle = document.createElement('style'); 1394 menuUpperStyle.id = 'menu-text-uppercase-style'; 1395 menuUpperStyle.textContent = '.menu-item,.horizontal-menu-label,a.horizontal-menu-label{text-transform:uppercase!important;font-weight:200!important;}'; 1396 document.head.appendChild(menuUpperStyle); 1397 } 1398} 1399 1400// Store initial styles in MenuStyleCustomizer if it exists 1401if (window.MenuStyleCustomizer) { 1402 window.MenuStyleCustomizer.settings = { 1403 ...window.MenuStyleCustomizer.defaults, 1404 ...initialMenuStyles 1405 }; 1406} 1407}); 1408</script>
vendor: 1 bytes, line 1408
1408
1409<script> 1410let magic = null; 1411let userMetadata = null; 1412let isAuthenticated = false; 1413let isAdmin = false; 1414let didToken = null; 1415let siteId = "btfkih7o"; 1416 1417/** Clear Hydra auth tokens/flags from this browser (used by logout button and ?logout=1 links). */ 1418function clearHydraClientAuthStorage() { 1419 try { 1420 localStorage.removeItem('hydra_is_admin'); 1421 localStorage.removeItem('hydra_auth_token'); 1422 localStorage.removeItem('hydra_auth_email'); 1423 localStorage.removeItem('hydra_did_token'); 1424 localStorage.removeItem('hydra_auth_site'); 1425 localStorage.removeItem('hydra_auth_last_refresh'); 1426 localStorage.removeItem('hydra_auth_auto_refresh'); 1427 } catch (e) {} 1428 if (window.TokenManager && typeof window.TokenManager.clearToken === 'function') { 1429 try { window.TokenManager.clearToken(); } catch (e) {} 1430 } 1431 isAuthenticated = false; 1432 isAdmin = false; 1433 didToken = null; 1434 userMetadata = null; 1435 window.isAuthenticated = false; 1436 window.isAdmin = false; 1437 window.didToken = null; 1438} 1439 1440function hydraUrlRequestsLogout() { 1441 try { 1442 var params = new URLSearchParams(window.location.search); 1443 var path = (window.location.pathname || '/').replace(/\/+$/, '') || '/'; 1444 if (params.get('logout') === '1' || params.get('logout') === 'true') return true; 1445 if (params.get('hydra_logout') === '1' || params.get('hydra_logout') === 'true') return true; 1446 return path === '/_manage/logout'; 1447 } catch (e) { 1448 return false; 1449 } 1450} 1451 1452function hydraCleanLogoutFromUrl() { 1453 try { 1454 var params = new URLSearchParams(window.location.search); 1455 var path = (window.location.pathname || '/').replace(/\/+$/, '') || '/'; 1456 var hadQuery = params.has('logout') || params.has('hydra_logout'); 1457 params.delete('logout'); 1458 params.delete('hydra_logout'); 1459 var nextPath = path === '/_manage/logout' ? '/_manage' : window.location.pathname; 1460 var qs = params.toString(); 1461 var next = nextPath + (qs ? '?' + qs : '') + (window.location.hash || ''); 1462 if (hadQuery || path === '/_manage/logout') { 1463 history.replaceState({}, '', next); 1464 } 1465 } catch (e) {} 1466} 1467 1468(function hydraLogoutFromUrlEarly() { 1469 if (!hydraUrlRequestsLogout()) return; 1470 clearHydraClientAuthStorage(); 1471 try { sessionStorage.setItem('hydra_logout_cleared', '1'); } catch (e) {} 1472 hydraCleanLogoutFromUrl(); 1473 console.log('[Hydra] Auth cleared via logout link/query.'); 1474})(); 1475 1476function hydraUrlRequestsRefresh() { 1477 try { 1478 var params = new URLSearchParams(window.location.search); 1479 var v = params.get('refresh'); 1480 return v === '1' || v === 'true'; 1481 } catch (e) { 1482 return false; 1483 } 1484} 1485 1486function hydraClearGalleryClientCache() { 1487 try { 1488 Object.keys(localStorage).filter(function (key) { 1489 return key.includes('gallery') || key.includes('neon') || key.includes('lightbox'); 1490 }
1490).forEach(function (key) { 1491 localStorage.removeItem(key); 1492 }); 1493 } catch (e) {} 1494 try { 1495 delete window.__neonGalleryMainBootstrapWipeDone; 1496 } catch (e) { 1497 window.__neonGalleryMainBootstrapWipeDone = false; 1498 } 1499 window._forceGalleryReload = true; 1500} 1501 1502function hydraCleanRefreshFromUrl() { 1503 try { 1504 var params = new URLSearchParams(window.location.search); 1505 if (!params.has('refresh')) return; 1506 params.delete('refresh'); 1507 var qs = params.toString(); 1508 var next = window.location.pathname + (qs ? '?' + qs : '') + (window.location.hash || ''); 1509 history.replaceState({}, '', next); 1510 } catch (e) {} 1511} 1512 1513(function hydraRefreshFromUrlEarly() { 1514 if (!hydraUrlRequestsRefresh()) return; 1515 clearHydraClientAuthStorage(); 1516 hydraClearGalleryClientCache(); 1517 try { sessionStorage.setItem('hydra_refresh_cleared', '1'); } catch (e) {} 1518 hydraCleanRefreshFromUrl(); 1519 console.log('[Hydra] Gallery cache/auth cleared via ?refresh=1 â forcing fresh load.'); 1520})(); 1521 1522// State Management â site-wide KV metadata for the editor (not per-page SEO) 1523window.siteMetadata = {"title":"Simon Akam","description":"Email [email protected] · Representation Patrick Walsh","googleAnalytics":"","noIndex":false,"favicon":"","ogImageUrl":"","contentLanguage":"en","copyrightEnabled":false,"copyrightText":"","customPublicSiteEnabled":false,"customPublicSiteHtmlR2Key":"","customPublicSiteHtmlFilename":""}; 1524console.log('Client Initialized site metadata:', window.siteMetadata); 1525 1526/** Match SSR title format: "{Gallery Title} | {Site Title}" */ 1527function formatGalleryDocumentTitle(galleryTitle, win) { 1528 win = win || window; 1529 var meta = win.siteMetadata; 1530 var siteTitle = (meta && meta.title && String(meta.title).trim()) || win.location.hostname; 1531 return (galleryTitle || 'Gallery') + ' | ' + siteTitle; 1532} 1533 1534console.log('Client Google Analytics from metadataToUse:', ''); 1535console.log('Client Google Analytics in window.siteMetadata:', window.siteMetadata.googleAnalytics); 1536console.log('Client Content Language:', window.siteMetadata.contentLanguage); 1537 1538// State Management 1539let galleries = [{"id":1754872130103,"title":"Spacer","parentId":null,"visible":true,"isSpacer":true},{"id":1754872727324,"title":"Simon, Start here...","isPage":true,"isIntegrated":true,"isSubmenu":false,"pageId":"qnx9or4j","visible":false,"parentId":null,"siteId":"btfkih7o","slug":"start-here","url":"/start-here","pageElements":[{"id":1754872731222,"type":"metadata","title":"Metadata","visible":true,"position":0,"metaTitle":"About","metaDescription":"","metaKeywords":""},{"id":1754872734681,"type":"text","title":"Text Block","visible":true,"position":1,"textContent":"<p> </p><p><br></p>","textWidth":100},{"id":1754872730270,"type":"column-container","title":"Two Column Layout","visible":true,"position":2,"columns":[{"id":1754870444536,"hAlign":"left","vAlign":"middle","elements":[{"id":1754870444336,"type":"text","title":"Text Block","visible":true,"position":0,"textContent":"<p style=\"margin-bottom: 0.5em;\"><br></p><p style=\"margin-bottom: 0.5em;\">Simon,</p><p style=\"margin-bottom: 0.5em;\"><br></p><p style=\"margin-bottom: 0.5em;\">To log into the new Site Manager...</p><p style=\"margin-bottom: 0.5em;\"><br></p><p style=\"margin-bottom: 0.5em;\">1) Press the <span style=\"color: rgb(255, 0, 200);\"> </span><span style=\"font-size: 24px; color: rgb(255, 0, 200);\">+</span><span style=\"font-size: 24px;\"> </span> key on your keyboard</p><p style=\"margin-bottom: 0.5em;\">2) Enter your email address.</p><p style=\"margin-bottom: 0.5em;\">3) Enter the passcode that you receive via your email...</p><p style=\"margin-bottom: 0.5em;\"><br></p><p style=\"margin-bottom: 0.5em;\">...then click the Edit button in the top-left corner.</p><p style=\"margin-bottom: 0.5em;\"><br></p><p style=\"margin-bottom: 0.5em;\">There's a video how-to below. </p><p style=\"margin-bottom: 0.5em;\"><br></p><p style=\"margin-bottom: 0.5em;\">The new manager suppors text editing and formatting much better than the old one. Let me know if you need help.</p><p style=\"margin-bottom: 0.5em;\"><br></p><p style=\"margin-bottom: 0.5em;\">Jayson</p><p style=\"margin-bottom: 0.5em;\">Neon Sky</p><p style=\"margin-bottom: 0.5em;\"><br></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><br></p>","textWidth":61}]}],"backgroundSlideshow":{"enabled":false,"slides":[],"slideDuration":5000,"transitionDuration":500,"slideshowHeight":50,"showFullImages":false}},{"id":1754872893447,"type":"embedded-content","title":"New Element","visible":true,"position":3,"contentUrl":"https://share.descript.com/embed/FyubGmgsVIG","containerWidth":81,"containerHeight":71,"useAutoHeight":false}
1539,{"id":1754872925074,"type":"text","title":"Text Block","visible":true,"position":4,"textContent":"<p> </p><p><br></p>","textWidth":100}],"classicGuid":"4bd5ec0737e49","importSource":"classic-page","isHomePage":false,"position":9,"metaTitle":"","metaDescription":"","hideMenuOnPage":false,"passwordProtected":false},{"id":1754870441126,"title":"Home","url":"/home","isIntegrated":true,"isPage":false,"isSubmenu":false,"visible":false,"parentId":null,"siteId":"btfkih7o","pageId":"jduz6xoh","classicGuid":"4bd5ec0737e49","importSource":"classic-gallery","slug":"home","normalizedName":"home","galleryOptions":{"manualCollectionName":"GUID=4bd5ec0737e49","importedFromClassic":true,"siteId":"btfkih7o","pageId":"jduz6xoh","horizontalScrollerText":"<p><br></p>","siteAlias":"preview.neonsky.app","initialPageUuid":"jduz6xoh","initialPageAlias":"home","timestamp":1754871740613,"startInSingles":true,"layoutType":"singles","columns":"3","spacing":2,"autoplaySingles":true,"autoplayDuration":"3","autoplayTransition":"1","showDescription":false,"descriptionsOnDemand":false,"showFilename":false,"displayAllInfo":false,"navigationMode":"dynamicCursor","showTextBlock":false,"fixedHeroImage":false,"zoomInLightbox":false,"lightboxOnMobile":false,"fadeDuration":"500","fadeInDuration":100,"descriptionTextColor":"0, 0, 0","gridImageOverlayColor":"130, 130, 130","gridImageOverlayOpacity":"0.5","lightboxBgColor":"255, 255, 255","lightboxBgOpacity":"1","lightboxCloseColor":"0, 0, 0","lightboxArrowColor":"0, 0, 0","useTitles":false,"useLinks":false,"desktopTitleDisplayMode":"overlay","titleTextAlign":"center","showDescriptionInOverlay":false,"includeRolloverImageInOverlay":true,"filterMenuEnabled":false,"filterMenuCollectionNames":"","filterMenuTitles":"","filterMenuStyle":"dropdown","rolloverSwap":false,"rolloverCollectionNames":"","openMultipleLightboxes":false,"batchSize":"20","jsonCollections":{"GUID=4bd5ec0737e49":{"isClassicCollection":true,"guid":"GUID=4bd5ec0737e49","metadata":{},"totalItems":0}}},"siteAlias":null,"isHomePage":true,"metaTitle":"Simon Akam","metaDescription":"Email [email protected] · Representation Patrick Walsh","hideMenuOnPage":false,"passwordProtected":false},{"id":1754870441128,"title":"The Changing Of The Guard","isFolder":true,"isCollapsed":false,"visible":true,"parentId":null,"siteId":"btfkih7o","slug":"the-changing-of-the-guard","importSource":"folder","isSubmenu":true,"isHomePage":false},{"id":1754870441129,"title":"About the book","isPage":true,"isIntegrated":true,"isSubmenu":false,"pageId":"page_1754870441129","visible":true,"parentId":1754870441128,"siteId":"btfkih7o","slug":"about-the-book","url":"/about-the-book","pageElements":[{"id":1754870442129,"type":"metadata","title":"Metadata","visible":true,"position":0,"metaTitle":"About the book","metaDescription":"","metaKeywords":""},{"id":1754872052274,"type":"text","title":"Text Block","visible":true,"position":1,"textContent":"<p> </p><p><br></p>","textWidth":100},{"id":1754870444129,"type":"column-container","title":"Two Column Layout","visible":true,"position":2,"columns":[{"id":1754870444429,"hAlign":"center","vAlign":"top","elements":[{"id":1754870444229,"type":"slideshow","title":"Page Images","visible":true,"position":0,"slides":[{"imageUrl":"https://storage.neonsky.app/btfkih7o/1754870931144_TheChangingOfTheGuard_B_concept_LT02.jpg","imageKey":"btfkih7o/1754870931144_TheChangingOfTheGuard_B_concept_LT02.jpg","imageFilename":"TheChangingOfTheGuard_B_concept_LT02.jpg","imageType":"image/jpeg"}],"slideDuration":5000,"transitionDuration":500,"slideshowWidth":94,"slideshowHeight":71,"showFullImages":true}]},{"id":1754870444529,"hAlign":"left","vAlign":"middle","elements":[{"id":1754870444329,"type":"text","title":"Text Block","visible":true,"position":0,"textContent":"<p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px;\"><b>A revelatory, explosive analysis of the military today</b></span></p><p>\n</p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px;\">Over the first two decades of the twenty-first century, Britain has changed enormously. During this time, the British Army fought two campaigns, in Iraq and Afghanistan, at considerable financial and human cost. Yet neither war achieved its objectives. This book questions why, and provides challenging but necessary answers.</span></p><p>\n</p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px;\">Composed from assiduous documentary research, field reportage, and hundreds of interviews with many soldiers and officers who served, as well as the politicians who directed them, the allies who accompanied them, and the family members who loved and â on occasion â lost them, it is a strikingly rich, nuanced portrait of one of our pivotal national institutions in a time of great stress.</span></p><p>\n</p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px;\">Award-winning journalist Simon Akam, who spent a year in the army when he was 18, returned a decade later to see how the institution had changed. His book examines the relevance of the armed forces today â their social, economic, political, and cultural role. This is as much a book about Britain, and about the politics of failure, as it is about the military.</span></p><p>\n</p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px;\">The Changing of the Guard was published by Scribe in February 2021. Read more <a href=\"https://scribepublications.co.uk/books-authors/books/the-changing-of-the-guard\" target=\"_blank\">here</a>.</span></p>","textWidth":92}]}],"backgroundSlideshow":{"enabled":false,"slides":[],"slideDuration":5000,"transitionDuration":500,"slideshowHeight":50,"showFullImages":false}}
1539,{"id":1754871039169,"type":"text","title":"Text Block","visible":true,"position":3,"textContent":"<p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">Endorsements to the book </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">Winner of the Templer First Book Award 2021 </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">âA âstate-of-the-nationâ book of resounding power, deep conviction and far-reaching significance.â Richard Davenport-Hines, Times Literary Supplement Books of the Year, 2021 </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">'Akamâs beautifully written, from the inside out, account of the British Armyâs reluctance to engage with the realities of recent small wars, in Afghanistan in particular, is a must-read for every serious student of modern military history. At one level, it explains how and why we managed to turn victory over al-Qaeda in Afghanistan into defeat at the hands of the Taliban. But this book is about much more than the army in Afghanistan â it is a parable about failure, the failure of a revered institution, with a proud history and an uncritical public, to come to terms with a changed and changing world.' Sir Sherard Cowper-Coles, former British Ambassador to Afghanistan. </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">'Simon Akam delivers a devastating indictment of Britainâs military chiefs for overseeing the shocking decline of the nationâs armed forces. His book is compulsory reading for every patriot.' Tom Bower, biographer. </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">'Simon Akam has written a perceptive, challenging and passionate book that looks at modern soldiering. In doing so, Akam provides an invaluable look at how the British Army works â and how the changing world in the 21st century is asking new and complex questions for soldiers and military strategy alike.' Peter Frankopan, author, The Silk Roads. </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">'This brave, absorbing and prodigiously well-researched tour de force renders every previous account of the British Army in its disastrous recent campaigns obsolete. Akam makes an unanswerable case that we are no longer very good at fighting wars, building his arguments with panache and good sense. In doing so he has done his country, and the army, a great service - although the Generals may not see it quite that way just yet. Put away the self-serving autobiographies and the obsequious histories of in-house academics; this is the definitive account of the British Army in its 21st century misadventures.' Frank Ledwidge, author, Losing Small Wars. </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">'[An] excellent and valuable book.' Jason Burke, The Guardian </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">âA powerful, compelling, and fascinating polemic.â William Boyd. </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">âMany will disagree with Akamâs conclusions, but it is important that arguments like his are given a proper airing ... I got through all 500-plus pages in two sittings â and it is certainly worth the effort.â Adrian Weale, Mail on Sunday </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">âA valuable addition to analysing the past, present, and future of a venerated institution.â The Independent </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">âFull of gripping reportage and compelling personal stories.â TLS </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">âPersuasive.â Matthew Parris, The Times </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">âDetailed and well structured.â Anthony Loyd, New Statesman </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">âA passionate book.â Max Hastings, The Sunday Times </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">âAkam is an angry young man and the book is better for it.â Roger Boyes, The Times </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">âA brilliant book ... Gets right to the heart of so many of the British Armyâs problems.â Simon Scarrow, Sunday Times bestselling author of The Eagles of the Empire series </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">âA timely, elegant, and important book.â David Patrikarakos, The Spectator </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">âThe military would obviously like to avoid a close examination of this unbroken string of catastrophes, but Akamâs book is a gentle account â critical, but not unsympathetic.â Tom Stevenson, London Review of Books </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">âA blistering account ... formidable.â Richard Norton-Taylor, Declassified UK </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">âLooks at the changes the British Army has undergone and roles it has played as an almost volunteer sidekick to the American military in the war on terror.â C.J. Chivers, The New York Times </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">âA scathing account of the British Army in the years after 9/11 ... full of forensic detail â and delicious gossip.â Shashank Joshi, The Spectator </span></p><p style=\"line-height: 1;\"><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\">âMakes many important points.â Helen Parr, Prospect </span></p><p style=\"line-height: 1;\"><br></p><p><span style=\"font-size: 14px;\">âBeautifully written, evocative and passionate prose ... [The] breadth of research gives Akamâs book immense power.â Nicholas Stuart, The Canberra Times </span></p><p><br></p><p><br></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px;\"><span class=\"ql-cursor\"></span></span></p>","textWidth":77}],"classicGuid":"4bd5ec0737e49","importSource":"classic-page","isHomePage":false},{"id":1754870441132,"title":"Always Take Notes","isFolder":true,"isCollapsed":true,"visible":true,"parentId":null,"siteId":"btfkih7o","slug":"always-take-notes","importSource":"folder","isSubmenu":true,"isHomePage":false},{"id":1754870441133,"title":"Podcast","isPage":true,"isIntegrated":true,"isSubmenu":false,"pageId":"page_1754870441133","visible":true,"parentId":1754870441132,"siteId":"btfkih7o","slug":"podcast","url":"/podcast","pageElements":[{"id":1754870442133,"type":"metadata","title":"Metadata","visible":true,"position":0,"metaTitle":"Podcast","metaDescription":"","metaKeywords":""}
1539,{"id":1754871910450,"type":"text","title":"Text Block","visible":true,"position":1,"textContent":"<p> </p><p><br></p>","textWidth":100},{"id":1754870444133,"type":"column-container","title":"Two Column Layout","visible":true,"position":2,"columns":[{"id":1754870444433,"hAlign":"center","vAlign":"top","elements":[{"id":1754870444233,"type":"slideshow","title":"Page Images","visible":true,"position":0,"slides":[{"imageUrl":"https://storage.neonsky.app/btfkih7o/1754871099230_ATN_SQUARE-LOGO_COL_300dpi.jpg","imageKey":"btfkih7o/1754871099230_ATN_SQUARE-LOGO_COL_300dpi.jpg","imageFilename":"ATN_SQUARE-LOGO_COL_300dpi.jpg","imageType":"image/jpeg"}],"slideDuration":5000,"transitionDuration":500,"slideshowWidth":88,"slideshowHeight":50,"showFullImages":true}]},{"id":1754870444533,"hAlign":"left","vAlign":"middle","elements":[{"id":1754870444333,"type":"text","title":"Text Block","visible":true,"position":0,"textContent":"<p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">Co-hosted by Simon Akam and </span><a href=\"https://x.com/rachelsllloyd\" target=\"_blank\" style=\"font-size: 14px; color: rgb(92, 92, 92);\">Rachel Lloyd</a><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">, </span><a href=\"https://www.alwaystakenotes.com/\" target=\"_blank\" style=\"font-size: 14px; color: rgb(92, 92, 92);\">Always Take Notes</a><span style=\"font-size: 14px; color: rgb(92, 92, 92);\"> is a podcast for and about writers and writing that aims to be informative (tackling vexing questions including how to negotiate fees, what makes an excellent book pitch and how to write for TV). We aim for a deliberately broad church, and past guests have included historians Peter Frankopan, Antony Beevor, Lucy Hughes-Hallett, Niall Ferguson and Max Hastings, novelists Anne Enright, Mark Haddon, Ian Rankin, Joanne Harris and Jeffrey Archer, journalists Sirin Kale, Patrick Radden Keefe and Hadley Freeman, and the editors of the Financial Times, the Times Literary Supplement, Cosmopolitan, Hello! and British GQ. The podcast is on iTunes </span><a href=\"https://itunes.apple.com/gb/podcast/always-take-notes/id1224996246?mt=2\" target=\"_blank\" style=\"font-size: 14px; color: rgb(92, 92, 92);\">here</a><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">; we are also on </span><a href=\"https://twitter.com/takenotesalways\" target=\"_blank\" style=\"font-size: 14px; color: rgb(92, 92, 92);\">Twitter</a><span style=\"font-size: 14px; color: rgb(92, 92, 92);\"> and </span><a href=\"https://www.instagram.com/alwaystakenotes/?hl=en\" target=\"_blank\" style=\"font-size: 14px; color: rgb(92, 92, 92);\">Instagram</a><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">. Our crowd funding page is on Patreon </span><a href=\"http://patreon.com/alwaystakenotes\" target=\"_blank\" style=\"font-size: 14px; color: rgb(92, 92, 92);\">here</a><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">.</span></p>","textWidth":86}]}],"backgroundSlideshow":{"enabled":false,"slides":[],"slideDuration":5000,"transitionDuration":500,"slideshowHeight":50,"showFullImages":false,"linkType":"none","linkUrl":"","linkPageId":"","linkTarget":"_self"},"backgroundVideo":{"enabled":false,"videoUrl":"","videoHeight":50}}],"classicGuid":"4bd5ec0737e49","importSource":"classic-page","isHomePage":false},{"id":1754870441134,"title":"Always Take Notes book","isPage":true,"isIntegrated":true,"isSubmenu":false,"pageId":"page_1754870441134","visible":true,"parentId":1754870441132,"siteId":"btfkih7o","slug":"always-take-notes-book","url":"/always-take-notes-book","pageElements":[{"id":1754870442134,"type":"metadata","title":"Metadata","visible":true,"position":0,"metaTitle":"Always Take Notes book","metaDescription":"","metaKeywords":""},{"id":1754872026429,"type":"text","title":"Text Block","visible":true,"position":1,"textContent":"<p> </p><p><br></p>","textWidth":100},{"id":1754870444134,"type":"column-container","title":"Two Column Layout","visible":true,"position":2,"columns":[{"id":1754870444434,"hAlign":"center","vAlign":"top","elements":[{"id":1754870444234,"type":"slideshow","title":"Page Images","visible":true,"position":0,"slides":[{"imageUrl":"https://storage.neonsky.app/btfkih7o/1754871141776_ATN-insta-square.jpg","imageKey":"btfkih7o/1754871141776_ATN-insta-square.jpg","imageFilename":"ATN-insta-square.jpg","imageType":"image/jpeg"}],"slideDuration":5000,"transitionDuration":500,"slideshowWidth":88,"slideshowHeight":50,"showFullImages":true}]},{"id":1754870444534,"hAlign":"left","vAlign":"middle","elements":[{"id":1754870444334,"type":"text","t
1539itle":"Text Block","visible":true,"position":0,"textContent":"<p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.hatchards.co.uk/book/always-take-notes/simon-akam/9781804183182\" target=\"_blank\" style=\"font-size: 14px; color: rgb(89, 89, 89);\">Always Take Notes: Advice from some of the world's greatest writers</a></p><p><span style=\"color: rgb(89, 89, 89);\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px; color: rgb(89, 89, 89);\">Bestselling and award-winning authors share the secrets to their success, and the hard lessons they have learnt along the way.</span></p><p><span style=\"color: rgb(89, 89, 89);\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px; color: rgb(89, 89, 89);\">Where do the best ideas come from? How do you stay motivated? What does it take to become a published author? And how do you actually make money from your writing? For over five years the hosts of Always Take Notes podcast have posed their nosiest questions to some of the world's greatest writers. The result is a compendium of frank and frequently entertaining guidance for living a creative life. From the early failures that shaped them to the daily challenges of writing and the habits that keep them on track, literary luminaries offer guidance to inspire.</span></p><p><span style=\"color: rgb(89, 89, 89);\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px; color: rgb(89, 89, 89);\">Featuring:</span></p><p><span style=\"color: rgb(89, 89, 89);\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px; color: rgb(89, 89, 89);\">Alexander McCall Smith, Anne Enright, Candice Carty-Williams, Christina Lamb, Colin Thubron, Colum McCann, David Mitchell, Elif Shafak, George Packer, Hadley Freeman, Hollie McNish, Ian McEwan, Ian Rankin, Irvine Welsh, Jeffrey Archer, Joanne Harris, Kate Mosse, Kiran Millwood Hargrave, Kit de Waal, Louise Doughty, Lucy Hughes-Hallett, Maggie Fergusson, Mark Haddon, Marlon James, Max Hastings, May Jeong, Merve Emre, Monica Ali, Niall Ferguson, Nikesh Shukla, Oliver Bullough, Orlando Figes, Patrick Kingsley, Rory Stewart, Rosie Nixon, Ruth Ozeki, Ruth Padel, Sam Knight, Samanth Subramanian, Samira Shackle, Sara Baume, Sebastian Junger, Simon Lancaster, Simon Scarrow, Stig Abell, Terri White, Tessa Hadley, Tim Rice, Toby Young, Tracy Chevalier, William Boyd, William Dalrymple, and many more...</span></p><p><span style=\"color: rgb(89, 89, 89);\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px; color: rgb(89, 89, 89);\">Publisher: Bonnier Books Ltd</span></p><p><span style=\"color: rgb(89, 89, 89);\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px; color: rgb(89, 89, 89);\">ISBN: 9781804183182</span></p>","textWidth":86}]}],"backgroundSlideshow":{"enabled":false,"slides":[],"slideDuration":5000,"transitionDuration":500,"slideshowHeight":50,"showFullImages":false}}],"classicGuid":"4bd5ec0737e49","importSource":"classic-page","isHomePage":false},{"id":1754870441135,"title":"Longform journalism","isPage":true,"isIntegrated":true,"isSubmenu":false,"pageId":"page_1754870441135","visible":true,"parentId":null,"siteId":"btfkih7o","slug":"longform-journalism","url":"/longform-journalism","pageElements":[{"id":1754870442135,"type":"metadata","title":"Metadata","visible":true,"position":0,"metaTitle":"Longform journalism","metaDescription":"","metaKeywords":""},{"id":1754872000818,"type":"text","title":"Text Block","visible":true,"position":1,"textContent":"<p> </p><p><br></p>","textWidth":100},{"id":1754870444135,"type":"column-container","title":"Two Column Layout","visible":true,"position":2,"columns":[{"id":1754870444435,"hAlign":"center","vAlign":"top","elements":[{"id":1754870444235,"type":"slideshow","title":"Page Images","visible":true,"position":0,"slides":[{"imageUrl":"https://storage.neonsky.app/btfkih7o/1754871269819_1117_FEA_Scotland-1---RGB.jpg","
1539imageKey":"btfkih7o/1754871269819_1117_FEA_Scotland-1---RGB.jpg","imageFilename":"1117_FEA_Scotland-1---RGB.jpg","imageType":"image/jpeg"},{"imageUrl":"https://storage.neonsky.app/btfkih7o/1754871268206_Bizweek-clerks.jpg","imageKey":"btfkih7o/1754871268206_Bizweek-clerks.jpg","imageFilename":"Bizweek-clerks.jpg","imageType":"image/jpeg"},{"imageUrl":"https://storage.neonsky.app/btfkih7o/1754871266106_Simon-1.jpg","imageKey":"btfkih7o/1754871266106_Simon-1.jpg","imageFilename":"Simon-1.jpg","imageType":"image/jpeg"}],"slideDuration":5000,"transitionDuration":500,"slideshowWidth":94,"slideshowHeight":73,"showFullImages":true}]},{"id":1754870444535,"hAlign":"left","vAlign":"middle","elements":[{"id":1754870444335,"type":"text","title":"Text Block","visible":true,"position":0,"textContent":"<p style=\"margin-bottom: 0.5em;\"><a href=\"https://www.vanityfair.com/news/story/glock-guns-family-feud?srsltid=AfmBOopnsCY0ku5BwsqMhfNWoENnJPyGwHThtDqS6S-Zh9dJO9biS0wK\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'Kiss Kiss Bang Bang,' Vanity Fair, published 2 April 2026</a></p><p style=\"margin-bottom: 0.5em;\"><br></p><p style=\"margin-bottom: 0.5em;\"><a href=\"https://www.newyorker.com/magazine/2025/12/08/a-very-big-fight-over-a-very-small-language\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'A Very Big Fight Over a Very Small Language,' The New Yorker, published 1 December 2025</a></p><p style=\"margin-bottom: 0.5em;\"><br></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.economist.com/1843/2024/05/13/why-is-britain-hopeless-at-punishing-corruption\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'Why is Britain hopeless at punishing corruption?,' Economist 1843, published 13 May 2024</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.theguardian.com/sport/2024/jan/30/precipice-of-fear-the-freerider-who-took-skiing-to-its-limits\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'Precipice of fear: the freerider who took skiing to its limits,' Guardian Long Read, published 30 January 2024</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.newyorker.com/sports/sporting-scene/the-man-who-completed-football\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'The Man Who âCompleted Footballâ,' the New Yorker, 10 November 2023</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.economist.com/1843/2023/01/29/bypassed-are-heart-surgeons-dying-out\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'Bypassed: are heart surgeons dying out?,' Economist 1843, published 29 January 2023</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.economist.com/1843/2022/04/14/prince-harrys-games-does-the-olympics-for-veterans-help-them-recover\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'Prince Harryâs games: does the Olympics for veterans help them recover?,' Economist 1843, published 14 April 2022</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.lrb.co.uk/the-paper/v44/n05/simon-akam/a-surfeit-of-rank\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'A Surfeit of Rank,' the London Review of Books, 10 March, 2022</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.newyorker.com/science/elements/a-new-story-for-stonehenge\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'A New Story for Stonehenge,' the New Yorker, 15 February, 2022</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.theglobeandmail.com/arts/books/article-excerpt-in-the-c
1539hanging-of-the-guard-simon-akam-takes-british-army-to/\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'Excerpt: In The Changing of the Guard, Simon Akam takes British Army to task for failing to learn from failure,' The Globe and Mail, 30 July, 2021</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.runnersworld.com/uk/health/injury/a37128768/history-of-blisters/\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'Thereâs the rub: the painful history of the blister,' Runner's World, published 26 July, 2021</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.outsideonline.com/outdoor-adventure/exploration-survival/crystal-hunters-chamonix-mont-blanc-france/\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'The Crystal Hunters of Chamonix,' Outside, published 8 June, 2021</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.gq-magazine.co.uk/politics/article/marine-a\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'War, justice and the real story behind âMarine A,â British GQ, published 3 April, 2021</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.economist.com/1843/2021/03/26/a-queue-of-ambulances-a-handful-of-beds-everyone-looked-to-her-for-solutions\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'A queue of ambulances. A handful of beds. Everyone looked to her for solutions,' Economist 1843, published 26 March, 2021</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.ft.com/content/468d42c9-c107-4518-9716-ca841076af30\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'Line of fire: what it is to be a man in todayâs British army,' Financial Times magazine, published 19 February, 2021 </a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.economist.com/1843/2020/11/26/on-the-hunt-with-yorkshires-virus-detectives\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'On the hunt with Yorkshireâs virus-detectives,' Economist 1843, published 26 November, 2020</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.economist.com/1843/2020/07/26/the-inside-story-of-britains-fight-against-covid-19\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'The inside story of Britainâs fight against covid-19,' Economist 1843, published 26 July, 2020</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.theguardian.com/tv-and-radio/2020/feb/04/magic-radio-indestructible-easy-listening-streaming\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'Magic moments: the indestructible appeal of easy listening radio,' Guardian Long Read, published 4 February, 2020</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.bloomberg.com/news/features/2020-03-06/the-awkward-ambition-of-elite-british-private-schools-abroad?sref=8XGseOCd\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'The Awkward Ambition of Elite British Private Schools Abroad,' Bloomberg Businessweek, published 6 March, 2020</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.outsideonline.com/2405575/chamonix-mountain-guide-test-probatoire\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'You Won't Pass Chamonix's Mountain-Guiding Test,' Outside, published 27 November, 2019</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.economist.com/1843/2019/11/18/death-in-the-alps\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'Death in the Alps, Economist 1843, published 18 November, 2019</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.bloomberg.com/news/features/2019-07-19/chronicles-of-the-last-quizmaster-in-the-age-of-big-trivia?sref=8XGseOCd\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'Chronicles of the Last Quizmaster in the Age of Big Trivia,' Bloomberg Businessweek, published 19 July, 2019</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.the-tls.co.uk/articles/lord-alfred-douglas-bosie-reputational-rehab/?utm_medium=Social&utm_source=Twitter#Echobox=1542994620\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'Bosie in reputational rehab,' the Times Literary Supplement, published 23 November, 2018</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://bleacherreport.com/articles/2780765-the-belgium-process\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'The Belgium Process,' Bleacher Report, published 13 June, 2018</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.theparisreview.org/blog/2018/02/23/portrait-of-a-friendship/\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'Portrait of a friendship,' the Paris Review, published 23 February, 2018</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.outsideonline.com/2254791/braveheart-couldnt-handle\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'Braveheart Couldn't Handle This,' Outside, published 26 October, 2017</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://bleacherreport.com/articles/2735519-youth-footballer-premier-league-clubs-recruiting\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'Inside the impossible dream of a six-year-old footballer,' Bleacher Report, published 28 September, 2017</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.bloomberg.com/news/features/2017-05-23/the-exquisitely-english-and-amazingly-lucrative-world-of-london-clerks\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'The Exquisitely English (and Amazingly Lucrative) World of London Clerks,' Bloomberg Businessweek, published 23 May, 2017</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://bleacherreport.com/articles/2708890-a-dream-that-died-on-the-pitch-the-tragic-story-of-patrick-ekeng\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'A Dream That Died on the Pitch: The Tragic Story of Patrick Ekeng,' Bleacher Report, published 11 May, 2017</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.gq-magazine.co.uk/article/viola-beach\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'The short life and mysterious death of Viola Beach,' British GQ, published 27 June, 2016</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.theguardian.com/business/2016/mar/15/british-umpire-how-institute-fiscal-studies-became-most-influential-voice-in-uk-economic-debate\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'The British umpire: how the IFS became the most influential voice in the economic debate,' Guardian Long Read, published 15 March, 2016 </a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.newsweek.com/londons-terror-protocols-plan-next-77-349008\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'London's Terror Protocols: The Plan for the Next Terrorism Attack,' Newsweek, published 1 July, 2015</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.newsweek.com/big-tobacco-fights-back-how-cigarette-kings-bought-vaping-industry-336432\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'Big Tobacco Fights Back: How the Cigarette Kings Bought the Vaping Industry,' Newsweek, published 27 May, 2015</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.newsweek.com/2014/12/26/murky-side-beautiful-game-life-bottom-english-football-292387.html\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">âThe Murky Side of the Beautiful Game: Life at the Bottom of English Football,â Newsweek, published 22 December, 2014</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.newsweek.com/2014/11/07/physical-and-mental-trauma-souvenirs-battle-afghanistan-280405.html\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">âSouvenirs of the War in Afghanistan: Many Troops Return With Physical and Mental Trauma,â Newsweek, published 28 October, 2014</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.newsweek.com/2014/10/03/knife-games-weird-weekend-worlds-top-plastic-surgeons-273439.html\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">âKnife Games: A Weird Weekend With the World's Top Plastic Surgeons,â Newsweek, published 26 September, 2014</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.newsweek.com/2014/09/12/scottish-referendum-two-friends-journey-heart-independence-debate-268158.html\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">âScottish Referendum: Two Friends Journey to the Heart of the Independence Debate,â Newsweek, published 4 September, 2014</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.theparisreview.org/blog/2013/10/10/the-diary-diaries/\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'The Diary Diaries,' the Paris Review, published 10 October, 2013</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.theparisreview.org/blog/2013/04/01/on-the-occasion-of-the-removal-of-my-girlfriends-dogs-balls/\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'On the Occasion of the Removal of My Girlfriendâs Dogâs Balls, the Paris Review, published 1 April, 2013</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.newstatesman.com/world-affairs/world-affairs/2012/05/letter-dakar\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'Letter from Dakar,' the New Statesman, published 16 May, 2012</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.newstatesman.com/africa/2012/01/sierra-leone-strasser-war\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'The vagabond king,' the New Statesman, published 2 February, 2012</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://newrepublic.com/article/95493/prince-johnson-liberia\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'The Comeback: A notorious ex-warlord hits the campaign trail in Liberia,' the New Republic, published 28 September, 2011</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"https://www.newstatesman.com/society/2009/06/native-american-gaming\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'Old wound, same pain,' the New Statesman, published 11 June, 2009</a></p><p><span style=\"color: rgb(69, 69, 69); font-size: 14px; font-family: Helvetica, sans-serif;\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"mailto:https://www.newstatesman.com/north-america/2008/10/virginia-state-vote-obama\" target=\"_blank\" style=\"font-size: 14px; color: rgb(69, 69, 69); font-family: Helvetica, sans-serif;\">'\"Weâre still fighting the Civil War here,\"' the New Statesman, published
153930 October, 2008</a></p>","textWidth":88}]}],"backgroundSlideshow":{"enabled":false,"slides":[],"slideDuration":5000,"transitionDuration":500,"slideshowHeight":50,"showFullImages":false,"linkType":"none","linkUrl":"","linkPageId":"","linkTarget":"_self"},"backgroundVideo":{"enabled":false,"videoUrl":"","videoHeight":50}}],"classicGuid":"4bd5ec0737e49","importSource":"classic-page","isHomePage":false},{"id":1754870441136,"title":"About","isPage":true,"isIntegrated":true,"isSubmenu":false,"pageId":"page_1754870441136","visible":true,"parentId":null,"siteId":"btfkih7o","slug":"about","url":"/about","pageElements":[{"id":1754870442136,"type":"metadata","title":"Metadata","visible":true,"position":0,"metaTitle":"About","metaDescription":"","metaKeywords":""},{"id":1754871895088,"type":"text","title":"Text Block","visible":true,"position":1,"textContent":"<p> </p><p><br></p>","textWidth":100},{"id":1754870444136,"type":"column-container","title":"Two Column Layout","visible":true,"position":2,"columns":[{"id":1754870444436,"hAlign":"center","vAlign":"top","elements":[{"id":1754870444236,"type":"slideshow","title":"Page Images","visible":true,"position":0,"slides":[{"imageUrl":"https://storage.neonsky.app/btfkih7o/1754871868012_akam_reed_headshot.jpg","imageKey":"btfkih7o/1754871868012_akam_reed_headshot.jpg","imageFilename":"akam_reed_headshot.jpg","imageType":"image/jpeg"}],"slideDuration":5000,"transitionDuration":500,"slideshowWidth":88,"slideshowHeight":50,"showFullImages":true}]},{"id":1754870444536,"hAlign":"left","vAlign":"middle","elements":[{"id":1754870444336,"type":"text","title":"Text Block","visible":true,"position":0,"textContent":"<p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">Simon Akam (</span><a href=\"https://x.com/simonakam\" target=\"_blank\" style=\"font-size: 14px; color: rgb(92, 92, 92);\">@simonakam)</a><span style=\"font-size: 14px; color: rgb(92, 92, 92);\"> is a British magazine journalist and author. His writing has appeared in publications including the New Yorker, Vanity Fair, the London Review of Books, The Economist's 1843 magazine, the Guardian Long Read, Outside, the New York Times, GQ and Bloomberg Businessweek. Simonâs first book, The Changing of the Guard â the British Army since 9/11, was published in 2021 to widespread acclaim. It won the Templer First Book Prize and the Times Literary Supplement, choosing a book of the year, described \"A state-of-the-nation book of resounding power, deep conviction and far-reaching significance.\" Simonâs second book, Snowlines, will be published by HarperCollins in the UK and Grove Atlantic in the US in early 2027. Simon also co-hosts the writing podcast </span><a href=\"https://www.alwaystakenotes.com/\" target=\"_blank\" style=\"font-size: 14px; color: rgb(92, 92, 92);\">Always Take Notes</a><span style=\"font-size: 14px; color: rgb(92, 92, 92);\"> with Rachel Lloyd.</span></p><p><span style=\"color: rgb(92, 92, 92);\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">Photograph taken by </span><a href=\"http://www.dudleyreed.com/\" target=\"_blank\" style=\"font-size: 14px; color: rgb(92, 92, 92);\">Dudley Reed</a><span style=\"font-size: 14px; color: rgb(92, 92, 92);\"> while a fellow at on the </span><a href=\"https://logannonfiction.org/\" target=\"_blank\" style=\"font-size: 14px; color: rgb(92, 92, 92);\">Logan Nonfiction Programme</a><span style=\"font-size: 14px; color: rgb(92, 92, 92);\"> at the Carey Institute for Global Good in upstate New York.</span></p>","textWidth":88}]}],"backgroundSlideshow":{"enabled":false,"slides":[],"slideDuration":5000,"transitionDuration":500,"slideshowHeight":50,"showFullImages":false,"linkType":"none","linkUrl":"","linkPageId":"","linkTarget":"_self"},"backgroundVideo":{"enabled":false,"videoUrl":"","videoHeight":50}}],"classicGuid":"4bd5ec0737e49","importSource":"classic-page","isHomePage":false},{"id":1754870441137,"title":"Contact","isPage":true,"isIntegrated":true,"isSubmenu":false,"pageId":"page_1754870441137","visible":true,"parentId":null,"siteId":"btfkih7o","slug":"contact","url":"/contact","pageElements":[{"id":1754871980822,"type":"text","t
1539itle":"Text Block","visible":true,"position":0,"textContent":"<p> </p><p><br></p>","textWidth":100},{"id":1754870442038,"type":"text","title":"Text Block","visible":true,"position":1,"textContent":"<p style=\"margin-bottom: 0.5em; line-height: 1;\"><br></p><p style=\"margin-bottom: 0.5em; line-height: 1;\"><br></p><p style=\"margin-bottom: 0.5em; line-height: 1;\"><br></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">Email </span><a href=\"mailto:[email protected]\" target=\"_blank\" style=\"font-size: 14px; color: rgb(92, 92, 92);\">[email protected]</a></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><strong style=\"font-size: 14px; color: rgb(92, 92, 92);\">Representation</strong><span style=\"font-size: 14px; color: rgb(92, 92, 92);\"> Patrick Walsh</span></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">PEW Literary Agency Limited</span></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">46 Lexington Street</span></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">Soho, London W1R OLP</span></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">+44 (0) 20 7734 4464</span></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">\n</span></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><a href=\"mailto:[email protected]\" target=\"_blank\" style=\"font-size: 14px; color: rgb(92, 92, 92);\">[email protected]</a></p><p style=\"line-height: 1;\"><span style=\"font-size: 14px; color: rgb(92, 92, 92);\">\n</span></p><p style=\"margin-bottom: 0.5em; line-height: 1;\"><a href=\"mailto:[email protected]\" target=\"_blank\" style=\"font-size: 14px; color: rgb(92, 92, 92);\">[email protected]</a></p><p style=\"margin-bottom: 0.5em; line-height: 1;\"><br></p><p style=\"margin-bottom: 0.5em; line-height: 1;\"><br></p><p style=\"line-height: 1; margin-bottom: 0.5em;\"><br></p>","textWidth":31},{"id":1754871910229,"type":"metadata","visible":true,"position":2,"metaTitle":"","metaDescription":"","metaKeywords":""}],"classicGuid":"4bd5ec0737e49","importSource":"classic-page","isHomePage":false,"position":10}]; 1540window.galleries = galleries; 1541 1542function getSiteGalleriesArray() { 1543 if (typeof galleries !== 'undefined' && Array.isArray(galleries)) { 1544 return galleries; 1545 } 1546 if (typeof window.galleries !== 'undefined' && Array.isArray(window.galleries)) { 1547 return window.galleries; 1548 } 1549 if (window.GALLERY_DATA_CACHE && 1550 window.GALLERY_DATA_CACHE.initialized && 1551 Array.isArray(window.GALLERY_DATA_CACHE.galleries)) { 1552 return window.GALLERY_DATA_CACHE.galleries; 1553 } 1554 return []; 1555} 1556window.getSiteGalleriesArray = getSiteGalleriesArray; 1557 1558let activeGalleryId = null; 1559let sortableInstance = null; 1560let isEditing = false; 1561let initialPageInfo = {"id":1754870441133,"type":"page","pageId":"page_1754870441133","slug":"podcast","url":"/podcast"}; 1562 1563 1564// Store the current menu layout type (must be JSON-encoded â bare sidebar/horizontal is invalid JS) 1565let currentMenuLayout = null; 1566 1567/** True when this document is loaded in the chromeless iframe (?neon_embed=1 or body.neon-page-embed). */ 1568function isNeonPageEmbedShell() { 1569 try { 1570 if (typeof document !== 'undefined' && document.body && document.body.classList.contains('neon-page-embed')) 1571 return true; 1572 var _spEmbed = new URLSearchParams(window.location.search); 1573 return _spEmbed.get('neon_embed') === '1'; 1574 } catch (_eEmbed) { return false; } 1575} 1576 1577/** 1578 * Mirrors "hide menu on this page": in embed shells always hide menus (unless editing), 1579 * otherwise use each gallery/page flag. 1580 */ 1581function shouldApplyMenuHiddenForEmbedOrGallery(gallery) { 1582 var editing = typeof window.isInEditMode === 'function' && window.isInEditMode(); 1583 if (editing) 1584 return !!(gallery && gallery.hideMenuOnPage); 1585 return !!(gallery && gallery.hideMenuOnPage) || isNeonPageEmbedShell(); 1586} 1587 1588function shouldApplyMenuOverlayOnPage(gallery) { 1589 if (shouldApplyMenuHiddenForEmbedOrGallery(gallery)) 1590 return false;
1591 return !!(gallery && gallery.overlayMenuOnPage); 1592} 1593 1594function isOverlayMenuChromeActive() { 1595 return document.body.classList.contains('menu-overlay-on-page') 1596 && !document.body.classList.contains('edit-mode-active'); 1597} 1598 1599/** Style Customizer paints sidebar with inline background-color !important; beat that only while overlay is active. */ 1600function syncOverlayMenuInlineChrome() { 1601 var sidebar = document.querySelector('.sidebar'); 1602 var header = document.querySelector('.mobile-header'); 1603 if (isOverlayMenuChromeActive()) { 1604 if (sidebar) { 1605 sidebar.style.setProperty('background-color', 'transparent', 'important'); 1606 sidebar.style.setProperty('background-image', 'none', 'important'); 1607 } 1608 if (header) { 1609 header.style.setProperty('background-color', 'transparent', 'important'); 1610 header.style.setProperty('background-image', 'none', 'important'); 1611 } 1612 return; 1613 } 1614 if (sidebar) { 1615 var menuBg = (getComputedStyle(document.documentElement).getPropertyValue('--menu-background-color') || '').trim() || '#fafafa'; 1616 var hidden = document.body.classList.contains('menu-background-hidden'); 1617 sidebar.style.setProperty('background-color', hidden ? 'transparent' : menuBg, 'important'); 1618 if (hidden) { 1619 sidebar.style.setProperty('background-image', 'none', 'important'); 1620 } else { 1621 sidebar.style.removeProperty('background-image'); 1622 } 1623 } 1624 if (header) { 1625 header.style.removeProperty('background-color'); 1626 header.style.removeProperty('background-image'); 1627 } 1628} 1629 1630function applyMenuOverlayClassForGallery(gallery) { 1631 if (shouldApplyMenuOverlayOnPage(gallery)) { 1632 document.body.classList.add('menu-overlay-on-page'); 1633 } else { 1634 document.body.classList.remove('menu-overlay-on-page'); 1635 } 1636 syncOverlayMenuInlineChrome(); 1637} 1638 1639function hookMenuStyleCustomizerForOverlay() { 1640 var customizer = window.MenuStyleCustomizer; 1641 if (!customizer || customizer.__overlayBgHooked || typeof customizer.applyStyles !== 'function') 1642 return; 1643 var original = customizer.applyStyles.bind(customizer); 1644 customizer.applyStyles = function() { 1645 original.apply(this, arguments); 1646 if (isOverlayMenuChromeActive()) 1647 syncOverlayMenuInlineChrome(); 1648 }; 1649 customizer.__overlayBgHooked = true; 1650} 1651window.applyMenuOverlayClassForGallery = applyMenuOverlayClassForGallery; 1652 1653document.addEventListener('DOMContentLoaded', () => { 1654 if (window.hydraInitialized) { 1655 console.log("Hydra already initialized, skipping"); 1656 return; 1657 } 1658 window.hydraInitialized = true; 1659 document.body.classList.add('hydra-initialized'); 1660 try { 1661 if (sessionStorage.getItem('hydra_refresh_cleared') === '1') { 1662 sessionStorage.removeItem('hydra_refresh_cleared'); 1663 if (window.EnhancedGallerySystem && typeof window.EnhancedGallerySystem.resetInitializationState === 'function') { 1664 window.EnhancedGallerySystem.resetInitializationState(); 1665 } 1666 console.log('[Hydra] Applied ?refresh=1 â gallery framework reset for clean load.'); 1667 } 1668 } catch (e) {} 1669 try { 1670 var neonEmbedParams = new URLSearchParams(window.location.search); 1671 if (neonEmbedParams.get('neon_embed') === '1') { 1672 document.body.classList.add('neon-page-embed', 'menu-hidden-on-page'); 1673 } 1674 } catch (_neonEmbedErr) {} 1675 hookMenuStyleCustomizerForOverlay(); 1676 setTimeout(hookMenuStyleCustomizerForOverlay, 0); 1677 setTimeout(hookMenuStyleCustomizerForOverlay, 500); 1678 if (isOverlayMenuChromeActive()) 1679 syncOverlayMenuInlineChrome(); 1680 window.Parameters = window.Parameters || {}; 1681 if (localStorage.getItem('hydra_is_admin') === 'true') { 1682 document.body.classList.add('hydra-admin', 'hydra-authenticated'); 1683 isAdmin = true; 1684 isAuthenticated = true; 1685 console.log('Restored admin status from localStorage'); 1686 1687 // CRITICAL FIX: Clear all caches except auth tokens for logged-in admins 1688 // This ensures admins always see fresh data when opening new tabs 1689 try { 1690 console.log('Admin detected - clearing all caches except auth tokens...'); 1691 1692 // Save auth tokens first 1693 const savedAuthToken = localStorage.getItem('hydra_auth_token'); 1694 const savedAuthEmail = localStorage.getItem('hydra_auth_email'); 1695 const savedIsAdmin = localStorage.getItem('hydra_is_admin'); 1696 const savedDidToken = localStorage.getItem('hydra_did_token'); 1697 1698 // Clear ALL localStorage 1699 localStorage.clear(); 1700 1701 // Restore auth tokens 1702 if (savedAuthToken) localStorage.setItem('hydra_auth_token', savedAuthToken); 1703 if (savedAuthEmail) localStorage.setItem('hydra_auth_email', savedAuthEmail); 1704 if (savedIsAdmin) localStorage.setItem('hydra_is_admin', savedIsAdmin); 1705 if (savedDidToken) localStorage.setItem('hydra_did_token', savedDidToken); 1706 1707 // Clear ALL sessionStorage (auth tokens are not stored here) 1708 sessionStorage.clear(); 1709 1710 console.log('Cleared all caches except auth tokens for admin user'); 1711 } catch (cacheError) { 1712 console.warn('Error clearing caches on admin page load:', cacheError); 1713 // Don't fail page load if cache clearing fails 1714 } 1715 } 1716 1717 function ensureSidebarElementsAndRender() { 1718 console.log('Ensuring sidebar elements are available before rendering...'); 1719 1720 // Restore from siteConfig when hydrate missed or init invented a default menu 1721 if (window.SidebarManager) { 1722 var current = window.SidebarManager.elements; 1723 var missingOrDefault = !current || current.length === 0 || window.SidebarManager._createdDefaultElements; 1724 if (missingOrDefault && typeof siteConfig !== 'undefined' && siteConfig.sidebarElements && siteConfig.sidebarElements.length > 0) { 1725 console.log('Initializing SidebarManager elements from siteConfig'); 1726 window.SidebarManager.elements = siteConfig.sidebarElements; 1727 window.SidebarManager._elementsReady = true; 1728 window.SidebarManager._createdDefaultElements = false;
1729 if (typeof window.SidebarManager.renderElements === 'function') { 1730 window.SidebarManager.renderElements(); 1731 } 1732 } 1733 } 1734 1735 // Determine layout and render 1736 const currentMenuLayout = getEffectiveMenuLayout(); 1737 1738 if (currentMenuLayout === 'horizontal') { 1739 console.log('Rendering horizontal menu with ensured sidebar elements'); 1740 renderHorizontalMenu(); 1741 } else { 1742 renderGalleries(); 1743 } 1744} 1745 1746 setTimeout(() => { 1747 console.log("Initializing galleries:", galleries.length); 1748 // Collapse folders before first paint so nested items never flash as expanded plain text 1749 alignFolderStates(); 1750 1751 // CHANGED: Use the new function instead of direct rendering 1752 setTimeout(ensureSidebarElementsAndRender, 100); // Small delay to ensure sidebar init 1753 1754 initMobileMenu(); 1755 1756 /** If true, deep-link timeouts below must not call handleURLNavigation again (duplicate loadPage / blink). */ 1757 let bootstrapRouteAttempted = false; 1758 1759 // Handle direct URL navigation 1760 if (initialPageInfo) { 1761 console.log("Loading initial page from direct URL:", initialPageInfo); 1762 1763 // Find the gallery by ID 1764 const gallery = galleries.find(g => g.id === initialPageInfo.id); 1765 1766 if (gallery) { 1767 // Set active gallery ID 1768 activeGalleryId = gallery.id; 1769 1770 if (initialPageInfo.type === 'page') { 1771 console.log("Loading page directly:", gallery.title); 1772 loadPage(gallery.id); 1773 } else { 1774 console.log("Loading gallery directly:", gallery.title); 1775 loadGallery(gallery.id); 1776 } 1777 bootstrapRouteAttempted = true; 1778 } 1779 } else if (window.location.pathname === '/') { 1780 // No specific path, try to load home page 1781 console.log("No specific path, checking for home page"); 1782 if (!loadHomePage() && galleries.length > 0) { 1783 // Default to first gallery if no home page is set 1784 loadGallery(galleries[0].id); 1785 } 1786 bootstrapRouteAttempted = true; 1787 } else if (galleries.length > 0) { 1788 // Path exists but no matching initialPageInfo, try to handle URL path 1789 handleURLNavigation(); 1790 bootstrapRouteAttempted = true; 1791 } 1792 window.__hydraBootstrapRouteAttempted = bootstrapRouteAttempted; 1793 1794 // Log what was rendered 1795 console.log("Menu content:", document.getElementById('galleryTree')?.innerHTML || "Gallery tree not found"); 1796 1797 // Start with edit controls properly set 1798 toggleEditControlsVisibility(isAdmin && document.body.classList.contains('edit-mode-active')); 1799 1800 // Explicitly hide the import classic form too 1801 const importForm = document.getElementById('importClassicForm'); 1802 if (importForm) { 1803 importForm.style.display = 'none'; 1804 importForm.classList.remove('visible'); 1805 } 1806 1807 // Try to restore token from localStorage 1808 if (!didToken) { 1809 didToken = localStorage.getItem('hydra_auth_token'); 1810 if (didToken) { 1811 console.log('Restored auth token from localStorage'); 1812 } 1813 } 1814 1815 // Initialize Magic and check authentication 1816 1817 1818 // Initialize sidebar elements if available 1819 if (window.SidebarManager) { 1820 // Pass the initial elements from server config if available 1821 if (true) { 1822 window.SidebarManager.elements = [{"id":1754867526798,"type":"menu","title":"Menu","visible":true,"position":1},{"id":1754872106425,"type":"text","title":"Text","visible":true,"position":0,"textContent":"<p>Simon Akam</p>"},{"id":1754877522531,"type":"social","title":"Social","visible":true,"position":3,"socialIconSize":20,"socialIcons":[{"type":"facebook","url":"https://facebook.com/simon.akam"},{"type":"x","url":"https://twitter.com//simonakam"},{"type":"instagram","url":"https://instagram.com/simon.akam"},{"type":"linkedin","url":"https://linkedin.com/in/simonakam/"}],"socialAlignment":"left"},{"id":1754877610513,"type":"image","title":"Image","visible":true,"position":2,"imageData":"https://storage.neonsky.app/btfkih7o/1754877628319_spacer.gif","imageWidth":"26%","imageAlignment":"center"}]; 1823 if (window.SidebarManager.elements && window.SidebarManager.elements.length > 0) { 1824 window.SidebarManager._elementsReady = true; 1825 window.SidebarManager._createdDefaultElements = false;
1826 } 1827 } 1828 window.SidebarManager.init(); 1829 } 1830 1831 1832 // Initialize metadata from server config (site-wide only â not current page SEO) 1833window.siteMetadata = {"title":"Simon Akam","description":"Email [email protected] · Representation Patrick Walsh","googleAnalytics":"","noIndex":false,"favicon":"","ogImageUrl":"","contentLanguage":"en","copyrightEnabled":false,"copyrightText":"","customPublicSiteEnabled":false,"customPublicSiteHtmlR2Key":"","customPublicSiteHtmlFilename":""}; 1834console.log('Client Initialized site metadata (second init):', window.siteMetadata); 1835console.log('Client Google Analytics from metadataToUse (second init):', ''); 1836console.log('Client Google Analytics in window.siteMetadata (second init):', window.siteMetadata.googleAnalytics); 1837console.log('Client Content Language (second init):', window.siteMetadata.contentLanguage); 1838 1839 // Update copyright footer after metadata is set (important for main domain) 1840 if (window.SidebarManager && typeof window.SidebarManager.updateMetadataFooter === 'function') { 1841 window.SidebarManager.updateMetadataFooter(); 1842 } 1843 1844 1845 1846 const submenuCheck = document.getElementById('createSubmenu'); 1847 const submenuTitleField = document.getElementById('submenuTitle'); 1848 const submenuTitleGroup = submenuTitleField?.parentElement; 1849 1850 if (submenuCheck && submenuTitleGroup) { 1851 submenuCheck.addEventListener('change', function() { 1852 submenuTitleGroup.style.display = this.checked ? 'block' : 'none'; 1853 }); 1854 } 1855 1856 // Check authentication state after initialization 1857 if (isAuthenticated && isAdmin) { 1858 console.log('Already authenticated and admin, showing edit UI'); 1859 forceShowEditUI(); 1860 } else if (didToken) { 1861 console.log('Have token, checking admin status'); 1862 checkAuth(); 1863 } 1864 }, 100); // Small delay to ensure everything is loaded 1865 1866 setTimeout(() => { 1867 if (window.location.pathname === '/' || 1868 window.location.pathname === '/_manage' || window.location.pathname === '/_manage/' || 1869 (window.location.hostname === 'preview.neonsky.app' && 1870 window.location.pathname.split('/').length <= 2)) { 1871 // Root URL or preview URL with only GUID - check for home page 1872 // Skip if bootstrap already routed (themed sites boot slower and would hit a duplicate load) 1873 if (!initialPageInfo && !window.__hydraBootstrapRouteAttempted) { 1874 loadHomePage(); 1875 } 1876 } 1877 }, 500); 1878 1879 // Dedicated editor URL: open edit tools or OTP login without requiring + key 1880 setTimeout(function hydraManageEditorEntry() { 1881 var p = window.location.pathname; 1882 if (p !== '/_manage' && p !== '/_manage/') return; 1883 try { 1884 if (sessionStorage.getItem('hydra_logout_cleared') === '1') { 1885 sessionStorage.removeItem('hydra_logout_cleared'); 1886 console.log('[Hydra] Logout link cleared auth; skipping auto-login on /_manage.'); 1887 return; 1888 } 1889 } catch (e) {} 1890 if (document.body.classList.contains('edit-mode-active')) return; 1891 try { 1892 var token = window.didToken || localStorage.getItem('hydra_auth_token'); 1893 var admin = window.isAdmin === true || localStorage.getItem('hydra_is_admin') === 'true'; 1894 if (token && admin) { 1895 if (typeof window.toggleEditMode === 'function') window.toggleEditMode(); 1896 return; 1897 } 1898 if (token && typeof checkAdminStatus === 'function') { 1899 checkAdminStatus(token).then(function() { 1900 if (window.isAdmin && typeof window.toggleEditMode === 'function' && !document.body.classList.contains('edit-mode-active')) { 1901 window.toggleEditMode(); 1902 } 1903 }).catch(function() {}); 1904 return; 1905 } 1906 if (typeof startOTPLogin === 'function') { 1907 var pr = startOTPLogin(); 1908 if (pr && typeof pr.catch === 'function') pr.catch(function() {}); 1909 } 1910 } catch (e) { 1911 console.error('hydraManageEditorEntry:', e); 1912 } 1913 }, 2200); 1914}); 1915 1916/** 1917 * Accessible name for header/sidebar brand images (no visual change). 1918 * Prefers element title or site metadata over generic "Image" / filenames. 1919 */ 1920function resolveBrandLogoAlt(element) { 1921 if (window.SidebarManager && typeof window.SidebarManager._resolveBrandImageAlt === 'function') { 1922 return window.SidebarManager._resolveBrandImageAlt(element || {}); 1923 } 1924 const siteTitle = 1925 (window.siteMetadata && String(window.siteMetadata.title || '').trim()) || 1926 (document.title && document.title.includes('|') ? document.title.split('|').pop().trim() : '') || 1927 String(location.hostname || '').replace(/^www\./i, ''); 1928 const title = element && element.title ? String(element.title).trim() : ''; 1929 if (title && !/^image(\s*\d*)?$/i.test(title)) return title; 1930 return siteTitle || 'Site logo'; 1931} 1932 1933/** 1934 * Gets the first visible sidebar element (image or text) to be used as a logo or title. 1935 * @returns {object|null} The first suitable sidebar element or null. 1936 */ 1937 1938function getFirstSidebarElementForHeader() { 1939 if (window.SidebarManager && window.SidebarManager.elements && window.SidebarManager.elements.length > 0) { 1940 const visibleElements = window.SidebarManager.elements.filter(el => el.visible !== false); // Filter 1: visible 1941 if (visibleElements.length > 0) { 1942 const sortedElements = visibleElements.sort((a, b) => a.position - b.position); 1943 1944 // Debug: Log all element types found 1945 const elementTypes = sortedElements.map(el => ({ type: el.type, id: el.id, title: el.title })); 1946 console.log('[getFirstSidebarElementForHeader] Visible elements found:', elementTypes); 1947 1948 for (let el of sortedElements) { 1949 if (el.type === 'image' || el.type === 'text') { // Filter 2: type 1950 console.log('[getFirstSidebarElementForHeader] Found matching element:', { type: el.type, id: el.id, title: el.title, linkType: el.linkType, linkUrl: el.linkUrl, linkPageId: el.linkPageId }); 1951 return el; 1952 } 1953 } 1954 // If loop finishes, no visible image or text found among visible elements 1955 console.log('[getFirstSidebarElementForHeader] Found visible elements, but none were type image/text. Element types found:', elementTypes.map(e => e.type)); 1956 return null; 1957 } else { 1958 console.log('[getFirstSidebarElementForHeader] No elements with visible !== false found in SidebarManager.elements. Total elements:', window.SidebarManager.elements.length); 1959 return null; 1960 } 1961 } 1962 console.log('[getFirstSidebarElementForHeader] SidebarManager, its elements, or elements array is empty/undefined.'); 1963 return null; 1964} 1965 1966/** 1967 * Gets the first visible social icon element from sidebar elements. 1968 * @returns {object|null} The first suitable social element or null. 1969 */ 1970function getFirstSocialElementForHeader() { 1971 if (window.SidebarManager && window.SidebarManager.elements && window.SidebarManager.elements.length > 0) { 1972 const visibleElements = window.SidebarManager.elements.filter(el => el.visible !== false); // Filter 1: visible 1973 if (visibleElements.length > 0) { 1974 const sortedElements = visibleElements.sort((a, b) => a.position - b.position); 1975 for (let el of sortedElements) { 1976 if (el.type === 'social') { // Filter for social type 1977 return el; 1978 } 1979 } 1980 // If loop finishes, no visible social element found 1981 console.log('[getFirstSocialElementForHeader] Found visible elements, but none were type social.'); 1982 return null; 1983 } else { 1984 console.log('[getFirstSocialElementForHeader] No elements with visible !== false found in SidebarManager.elements.'); 1985 return null; 1986 } 1987 } 1988 console.log('[getFirstSocialElementForHeader] SidebarManager, its elements, or elements array is empty/undefined.'); 1989 return null; 1990} 1991 1992function isSpaceColonySiteTheme() { 1993 const themeId = document.body && document.body.dataset.siteTheme; 1994 return themeId === 'space-colony'; 1995} 1996 1997/** 1998 * Apply horizontal bar logo placement (inline vs stacked) from Menu Style > Bar logo & menu. 1999 * Sets body.horizontal-header-logo-stacked and flex on .logo-menu-container; updates --mobile-header-height when stacked. 2000 */ 2001function applyHorizontalLogoMenuLayoutStyles() { 2002 const sc = window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings; 2003 const mode = (sc && sc.horizontalHeaderLogoLayout) || 'inline'; 2004 const isSpaceColonyTheme = isSpaceColonySiteTheme(); 2005 document.body.classList.toggle('horizontal-header-logo-stacked', mode === 'stacked'); 2006 const w = document.querySelector('.logo-menu-container'); 2007 if (w) { 2008 const isMobile = window.innerWidth <= 768; 2009 w.style.display = 'flex'; 2010 w.style.alignItems = isSpaceColonyTheme && !isMobile ? 'flex-end' : 'center'; 2011 if (mode === 'stacked') { 2012 w.style.flexDirection = 'column'; 2013 w.style.gap = isMobile ? '8px' : '12px'; 2014 w.style.width = '100%'; 2015 w.style.boxSizing = 'border-box';
2016 w.style.flexGrow = isMobile ? '0' : '1'; 2017 w.style.minWidth = '0'; 2018 w.style.justifyContent = isMobile ? (isSpaceColonyTheme ? 'flex-start' : 'center') : 'flex-start'; 2019 } else { 2020 w.style.flexDirection = 'row'; 2021 w.style.gap = '20px'; 2022 w.style.width = ''; 2023 w.style.boxSizing = ''; 2024 w.style.minWidth = ''; 2025 if (isMobile) { 2026 w.style.flexGrow = '0'; 2027 w.style.justifyContent = isSpaceColonyTheme ? 'flex-start' : 'center'; 2028 } else { 2029 w.style.flexGrow = ''; 2030 w.style.justifyContent = ''; 2031 } 2032 } 2033 } 2034 const mhc = document.querySelector('.mobile-header-content'); 2035 if (mhc) { 2036 const isMobile = window.innerWidth <= 768; 2037 if (isSpaceColonyTheme && !isMobile) { 2038 mhc.style.alignItems = 'flex-end'; 2039 mhc.style.flexGrow = '0'; 2040 mhc.style.width = ''; 2041 } else if (isSpaceColonyTheme && isMobile) { 2042 mhc.style.alignItems = 'flex-start'; 2043 mhc.style.justifyContent = 'flex-start'; 2044 mhc.style.flexGrow = ''; 2045 mhc.style.width = '100%'; 2046 } else if (!isMobile && mode === 'stacked') { 2047 mhc.style.alignItems = 'flex-start'; 2048 mhc.style.flexGrow = ''; 2049 mhc.style.width = ''; 2050 } else { 2051 mhc.style.alignItems = ''; 2052 mhc.style.flexGrow = ''; 2053 mhc.style.width = ''; 2054 } 2055 } 2056 if (typeof window.syncContentMarginToHorizontalHeader === 'function') { 2057 window.syncContentMarginToHorizontalHeader(); 2058 } 2059} 2060window.applyHorizontalLogoMenuLayoutStyles = applyHorizontalLogoMenuLayoutStyles; 2061 2062function parseHorizontalHeaderHeightPx(value, fallback) { 2063 if (value === null || value === undefined) { 2064 return fallback; 2065 } 2066 const parsed = parseInt(String(value).trim(), 10); 2067 if (Number.isNaN(parsed) || parsed < 1) { 2068 return fallback; 2069 } 2070 return parsed; 2071} 2072 2073function getUserHorizontalHeaderHeightPx() { 2074 if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings) { 2075 const fromSetting = parseHorizontalHeaderHeightPx(window.MenuStyleCustomizer.settings.mobileHeaderHeight, 0); 2076 if (fromSetting > 0) { 2077 return fromSetting; 2078 } 2079 } 2080 const fromVar = getComputedStyle(document.documentElement).getPropertyValue('--top-bar-height-setting').trim(); 2081 const parsedVar = parseHorizontalHeaderHeightPx(fromVar, 0); 2082 if (parsedVar > 0) { 2083 return parsedVar; 2084 } 2085 const themeMin = getComputedStyle(document.documentElement).getPropertyValue('--st-header-height').trim(); 2086 const parsedTheme = parseHorizontalHeaderHeightPx(themeMin, 0); 2087 if (parsedTheme > 0) { 2088 return parsedTheme; 2089 } 2090 return 60; 2091} 2092 2093function setHorizontalHeaderHeightVars(headerPx, contentMarginPx) { 2094 const headerVal = headerPx + 'px'; 2095 const marginVal = contentMarginPx + 'px'; 2096 const currentHeader = getComputedStyle(document.documentElement).getPropertyValue('--mobile-header-height').trim(); 2097 const currentMargin = getComputedStyle(document.documentElement).getPropertyValue('--content-margin-top').trim(); 2098 if (currentHeader === headerVal && currentMargin === marginVal) { 2099 return; 2100 } 2101 document.documentElement.style.setProperty('--mobile-header-height', headerVal); 2102 document.documentElement.style.setProperty('--content-margin-top', marginVal); 2103} 2104 2105function measureHorizontalHeaderNaturalHeight(header) { 2106 const el = header; 2107 const prevHeight = el.style.getPropertyValue('height'); 2108 const prevMinHeight = el.style.getPropertyValue('min-height'); 2109 /* Beat CSS min-height: var(--mobile-header-height) !important so a prior wrap 2110 doesnât floor the measurement when the viewport widens back to one row. */ 2111 el.style.setProperty('height', 'auto', 'important'); 2112 el.style.setProperty('min-height', '0', 'important'); 2113 const measured = Math.ceil(el.getBoundingClientRect().height); 2114 if (prevHeight) { 2115 el.style.setProperty('height', prevHeight); 2116 } else { 2117 el.style.removeProperty('height'); 2118 } 2119 if (prevMinHeight) { 2120 el.style.setProperty('min-height', prevMinHeight); 2121 } else { 2122 el.style.removeProperty('min-height'); 2123 } 2124 return measured; 2125} 2126 2127function applyHorizontalHeaderContentInset() { 2128 if (!document.body.classList.contains('menu-layout-horizontal')) { 2129 return; 2130 } 2131 if (window._horizontalHeaderInsetApplying) { 2132 return; 2133 } 2134 const header = document.querySelector('.mobile-header'); 2135 if (!header) { 2136 return; 2137 } 2138 2139 window._horizontalHeaderInsetApplying = true; 2140 try { 2141 const userH = getUserHorizontalHeaderHeightPx();
2142 /* Keep logo-cap var as a real px height. Sites can save mobileHeaderHeight as 0vh; 2143 leaving that on --top-bar-height-setting makes calc(var(...) - 8px) â 0 and hides the logo. */ 2144 document.documentElement.style.setProperty('--top-bar-height-setting', userH + 'px'); 2145 const hasExpandedMenu = header.querySelector('.horizontal-menu-item.expanded, .horizontal-menu-item.submenu-open'); 2146 const isStackedLogo = document.body.classList.contains('horizontal-header-logo-stacked'); 2147 const isPhone = window.innerWidth <= 768; 2148 2149 /* 2150 * Phone hamburger chrome: fixed Style Customizer height (link row is hidden). 2151 * Desktop inline may wrap beside the logo â measure like stacked so the bar can grow. 2152 * Stacked / open submenu: always measure. 2153 */ 2154 if (!hasExpandedMenu && !isStackedLogo && isPhone) { 2155 setHorizontalHeaderHeightVars(userH, userH); 2156 return; 2157 } 2158 2159 /* Desktop inline (wrap-capable), stacked logo, or open submenu */ 2160 const measured = measureHorizontalHeaderNaturalHeight(header); 2161 if (!measured || measured < 1) { 2162 setHorizontalHeaderHeightVars(userH, userH); 2163 return; 2164 } 2165 2166 let maxH = Math.max(200, Math.round(window.innerHeight * 0.5)); 2167 const clamped = Math.min(Math.max(measured, userH), maxH); 2168 /* Extra content margin only when bar grew past the user setting (wrapped / stacked / open menu) */ 2169 const contentMargin = clamped > userH ? clamped + 8 : clamped; 2170 setHorizontalHeaderHeightVars(clamped, contentMargin); 2171 } finally { 2172 window._horizontalHeaderInsetApplying = false; 2173 } 2174} 2175 2176function ensureHorizontalHeaderHeightObserver() { 2177 if (typeof ResizeObserver === 'undefined') { 2178 return; 2179 } 2180 const header = document.querySelector('.mobile-header'); 2181 if (!header) { 2182 return; 2183 } 2184 if (window._horizontalHeaderResizeObserver) { 2185 window._horizontalHeaderResizeObserver.disconnect(); 2186 } 2187 window._horizontalHeaderResizeObserver = new ResizeObserver(function() { 2188 applyHorizontalHeaderContentInset(); 2189 }); 2190 window._horizontalHeaderResizeObserver.observe(header); 2191} 2192 2193function syncContentMarginToHorizontalHeader() { 2194 if (!document.body.classList.contains('menu-layout-horizontal')) { 2195 return; 2196 } 2197 requestAnimationFrame(function() { 2198 requestAnimationFrame(function() { 2199 applyHorizontalHeaderContentInset(); 2200 ensureHorizontalHeaderHeightObserver(); 2201 }); 2202 }); 2203} 2204window.syncContentMarginToHorizontalHeader = syncContentMarginToHorizontalHeader; 2205window.applyHorizontalHeaderContentInset = applyHorizontalHeaderContentInset; 2206 2207function getEffectiveMenuLayout() { 2208 if (window.MenuStyleCustomizer && typeof window.MenuStyleCustomizer.getEffectiveMenuLayout === 'function') { 2209 return window.MenuStyleCustomizer.getEffectiveMenuLayout(); 2210 } 2211 if (document.body.classList.contains('menu-layout-horizontal')) return 'horizontal'; 2212 if (document.body.classList.contains('menu-layout-top')) return 'top'; 2213 if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings) { 2214 return window.MenuStyleCustomizer.settings.menuLayout || 'sidebar'; 2215 } 2216 return 'sidebar'; 2217} 2218 2219function ensureCorrectLayoutApplied() { 2220 // Check if MenuStyleCustomizer and its necessary parts exist 2221 if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings && typeof window.MenuStyleCustomizer._applyMenuLayout === 'function') { 2222 // Prefer in-editor settings (e.g. Style History preview) over theme/body heuristics 2223 const fromSettings = window.MenuStyleCustomizer.settings.menuLayout; 2224 const currentLayout = fromSettings || getEffectiveMenuLayout(); 2225 console.log(`Ensuring layout is applied: ${currentLayout}`); 2226 2227 // Apply the layout styles (CSS classes, display properties) 2228 window.MenuStyleCustomizer._applyMenuLayout(currentLayout); 2229 2230 // --- Force Header Structure Rebuild for Horizontal Layout --- 2231 if (currentLayout === 'horizontal') { 2232 const mobileHeader = document.querySelector('.mobile-header'); 2233 if (mobileHeader) { 2234 let mobileHeaderContent = mobileHeader.querySelector('.mobile-header-content'); 2235 // Ensure the main content container exists 2236 if (!mobileHeaderContent) { 2237 mobileHeaderContent = document.createElement('div'); 2238 mobileHeaderContent.className = 'mobile-header-content'; 2239 const hamburgerBtn = mobileHeader.querySelector('.hamburger-btn'); 2240 if (hamburgerBtn) { 2241 mobileHeader.insertBefore(mobileHeaderContent, hamburgerBtn); 2242 } else { 2243 mobileHeader.appendChild(mobileHeaderContent); 2244 } 2245 } 2246 // Ensure specific sub-containers exist, creating them if necessary 2247 // This guarantees the structure is present before renderHorizontalMenu fills them. 2248 if (!mobileHeaderContent.querySelector('.horizontal-header-logo')) { 2249 const logoContainer = document.createElement('div'); 2250 logoContainer.className = 'horizontal-header-logo'; 2251 mobileHeaderContent.insertBefore(logoContainer, mobileHeaderContent.firstChild); 2252 } 2253 if (!mobileHeaderContent.querySelector('.horizontal-menu
2253-container')) { 2254 const menuContainer = document.createElement('div'); 2255 menuContainer.className = 'horizontal-menu-container'; 2256 const logoContainer = mobileHeaderContent.querySelector('.horizontal-header-logo'); 2257 if (logoContainer) { 2258 logoContainer.insertAdjacentElement('afterend', menuContainer); 2259 } else { // Fallback if logo container wasn't found/created 2260 mobileHeaderContent.insertBefore(menuContainer, mobileHeaderContent.firstChild); 2261 } 2262 } 2263 // Apply flexbox styling to position social container at far right 2264 mobileHeaderContent.style.display = 'flex'; 2265 const isSpaceColonyTheme = isSpaceColonySiteTheme(); 2266 const isMobile = window.innerWidth <= 768; 2267 mobileHeaderContent.style.alignItems = isSpaceColonyTheme && !isMobile ? 'flex-end' : (isSpaceColonyTheme && isMobile ? 'flex-start' : 'center'); 2268 mobileHeaderContent.style.justifyContent = isMobile ? (isSpaceColonyTheme ? 'flex-start' : 'center') : (isSpaceColonyTheme ? 'flex-start' : 'space-between'); 2269 if (isSpaceColonyTheme && !isMobile) { 2270 mobileHeaderContent.style.flexGrow = '0'; 2271 mobileHeaderContent.style.width = ''; 2272 } else { 2273 mobileHeaderContent.style.flexGrow = ''; 2274 mobileHeaderContent.style.width = '100%'; 2275 } 2276 2277 // Create a flex container for logo and menu items 2278 const logoMenuContainer = mobileHeaderContent.querySelector('.logo-menu-container'); 2279 if (!logoMenuContainer) { 2280 console.log('[ensureCorrectLayoutApplied] Creating logo-menu wrapper...'); 2281 const logoMenuWrapper = document.createElement('div'); 2282 logoMenuWrapper.className = 'logo-menu-container'; 2283 // Move logo and menu into the wrapper 2284 const logoContainer = mobileHeaderContent.querySelector('.horizontal-header-logo'); 2285 const menuContainer = mobileHeaderContent.querySelector('.horizontal-menu-container'); 2286 2287 console.log('[ensureCorrectLayoutApplied] Found logo container:', !!logoContainer); 2288 console.log('[ensureCorrectLayoutApplied] Found menu container:', !!menuContainer); 2289 2290 if (logoContainer && menuContainer) { 2291 // Insert wrapper before the logo 2292 mobileHeaderContent.insertBefore(logoMenuWrapper, logoContainer); 2293 // Move both containers into the wrapper 2294 logoMenuWrapper.appendChild(logoContainer); 2295 logoMenuWrapper.appendChild(menuContainer); 2296 console.log('[ensureCorrectLayoutApplied] Successfully moved logo and menu into wrapper'); 2297 } else { 2298 console.warn('[ensureCorrectLayoutApplied] Missing logo or menu container, cannot create wrapper'); 2299 } 2300 } else { 2301 console.log('[ensureCorrectLayoutApplied] Logo-menu wrapper already exists'); 2302 } 2303 if (typeof applyHorizontalLogoMenuLayoutStyles === 'function') { 2304 applyHorizontalLogoMenuLayoutStyles(); 2305 } 2306 2307 // Add social icons container for horizontal layout (AFTER logo-menu wrapper is created) 2308 if (!mobileHeaderContent.querySelector('.horizontal-social-container')) { 2309 const socialContainer = document.createElement('div'); 2310 socialContainer.className = 'horizontal-social-container'; 2311 // Insert at the end (far right) of the mobile header content 2312 mobileHeaderContent.appendChild(socialContainer); 2313 } 2314 } 2315 } 2316 // --- End Header Structure Rebuild --- 2317 2318 2319 // Re-render the correct menu structure based on the applied layout 2320 if (currentLayout === 'horizontal') { 2321 // If horizontal layout, render the horizontal menu 2322 if (typeof renderHorizontalMenu === 'function') { 2323 renderHorizontalMenu(); 2324 } else { 2325 console.warn("renderHorizontalMenu function not found."); 2326 } 2327 2328 // After rendering horizontal menu, update mobile header content 2329 // This ensures mobile header shows logo instead of menu items on mobile 2330 setTimeout(() => { 2331 if (typeof updateMobileHeaderContent === 'function') { 2332 updateMobileHeaderContent(); 2333 } 2334 }, 100); 2335 } else { 2336 // For sidebar or top layouts, render the standard gallery tree 2337 if (typeof renderGalleries === 'function') { 2338 renderGalleries(); 2339 } else { 2340 console.warn("renderGalleries function not found."); 2341 } 2342 2343 // Update mobile header content for other layouts too 2344 setTimeout(() => { 2345 if (typeof updateMobileHeaderContent === 'function') { 2346 updateMobileHeaderContent(); 2347 } 2348 }, 100); 2349 } 2350 } else { 2351 // Log a warning if the necessary components aren't available 2352 console.warn("MenuStyleCustomizer or its methods not available to re-apply layout."); 2353 } 2354} 2355 2356function applyLayoutStylesOnly() { 2357 // Check if MenuStyleCustomizer and its necessary parts exist 2358 if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings && typeof window.MenuStyleCustomizer._applyMenuLayout === 'function') { 2359 // Get the current layout setting, default to 'sidebar' if not set 2360 const currentLayout = window.MenuStyleCustomizer.settings.menuLayout || 'sidebar'; 2361 console.log(`APPLY STYLES: Applying styles for layout: ${currentLayout}`); 2362 // Apply the layout styles (CSS classes, display properties) 2363 window.MenuStyleCustomizer._applyMenuLayout(currentLayout); // This function should ONLY set body classes/styles 2364 } else { 2365 console.warn("APPLY STYLES: MenuStyleCustomizer or its methods not available."); 2366 } 2367} 2368 2369// Add this function to ensure sidebar content for mobile 2370function ensureSidebarContentForMobile() { 2371 const isHorizontalLayout = document.body.classList.contains('menu-layout-horizontal'); 2372 const isMobileView = window.innerWidth <= 768; 2373 2374 if (isHorizontalLayout && isMobileView) { 2375 console.log('Ensuring sidebar content for mobile horizontal layout'); 2376 2377 // Make sure the sidebar tree is populated 2378 const galleryTreeContainer = document.getElementById('galleryTree'); 2379 if (galleryTreeContainer && (!galleryTreeContainer.innerHTML || galleryTreeContainer.innerHTML.trim() === '')) { 2380 console.log('Sidebar tree is empty, populating with galleries'); 2381 2382 // Render the galleries in the sidebar tree for mobile navigation 2383 if (typeof renderGalleries === 'function') { 2384 renderGalleries(); 2385 } 2386 } 2387 2388 // Also ensure sidebar elements are rendered 2389 if (window.SidebarManager && typeof window.SidebarManager.renderElements === 'function') { 2390 console.log('Re-rendering sidebar elements for mobile'); 2391 window.SidebarManager.renderElements(); 2392 } 2393 } 2394} 2395 2396function renderTextLogo(container, htmlContentFromSidebarElement) { 2397 if (!container) { 2398 console.error("[RenderTextLogo] Error: Logo container not found."); 2399 return; 2400 } 2401 2402 container.innerHTML = ''; 2403 2404 const raw = (htmlContentFromSidebarElement || '').trim(); 2405 if (!raw) { 2406 container.innerHTML = '<span class="site-title-text">Menu</span>'; 2407 } else if (!/<[a-z][sS]*>/i.test(raw)) { 2408 // Plain text â wrap so theme CSS can target .site-title-text 2409 container.innerHTML = '<span class="site-title-text">' + raw + '</span>'; 2410 } else { 2411 container.innerHTML = raw; 2412 const titleEl = container.querySelector('.site-title-text, h1, h2, p:last-of-type, p:only-of-type'); 2413 if (titleEl && !titleEl.classList.contains('site-title-text')) { 2414 titleEl.classList.add('site-title-text'); 2415 } 2416 } 2417
2418 // Do not force heading/paragraph margins to 0 â menu-styles.css matches 2419 // .sidebar-text rhythm (h1 margin-bottom 0.5em). Zeroing made the bar tighter. 2420 2421 console.log(`[RenderTextLogo] Text logo HTML rendered directly into container.`); 2422} 2423 2424function renderHorizontalMenu() { 2425 console.log("[RenderHorizontalMenu] Function CALLED. Current edit mode:", typeof isEditing !== 'undefined' ? isEditing : 'isEditing_undefined'); 2426 2427 // CRITICAL: Check if we're actually in horizontal layout (theme overlay counts) 2428 const currentLayout = getEffectiveMenuLayout(); 2429 2430 const isHorizontalLayout = currentLayout === 'horizontal'; 2431 console.log("[RenderHorizontalMenu] Current layout:", currentLayout, "isHorizontalLayout:", isHorizontalLayout); 2432 2433 // If not horizontal layout, only handle logo/alignment, then return early 2434 if (!isHorizontalLayout) { 2435 console.log("[RenderHorizontalMenu] Not in horizontal layout, only handling logo/alignment, skipping menu item creation"); 2436 // Still handle logo/alignment for sidebar layout 2437 const mobileHeader = document.querySelector('.mobile-header'); 2438 if (mobileHeader) { 2439 const logoContainer = mobileHeader.querySelector('.horizontal-header-logo'); 2440 if (logoContainer) { 2441 // Handle logo rendering only (code below will handle this) 2442 // But we'll return before creating menu items 2443 } 2444 } 2445 // Return early - don't create horizontal menu items in sidebar layout 2446 return; 2447 } 2448 2449 // Wait for sidebar elements to be available before rendering 2450 if (!window.SidebarManager || !window.SidebarManager.elements || window.SidebarManager.elements.length === 0) { 2451 console.log("[RenderHorizontalMenu] Sidebar elements not yet available, waiting..."); 2452 const waitForSidebarElements = (attempts = 0) => { 2453 const maxAttempts = 20; // Increased attempts for slower loads 2454 if (window.SidebarManager && window.SidebarManager.elements && window.SidebarManager.elements.length > 0) { 2455 console.log("[RenderHorizontalMenu] Sidebar elements now available, proceeding with render."); 2456 renderHorizontalMenu(); 2457 } else if (attempts < maxAttempts) { 2458 const attemptNum = attempts + 1; 2459 setTimeout(() => waitForSidebarElements(attempts + 1), 100); 2460 } else { 2461 console.warn("[RenderHorizontalMenu] Sidebar elements not available after waiting, rendering anyway."); 2462 // Continue with render even if sidebar elements aren't loaded 2463 } 2464 }; 2465 waitForSidebarElements(); 2466 return; 2467 } 2468 2469 const mobileHeader = document.querySelector('.mobile-header'); 2470 if (!mobileHeader) { 2471 console.error("[RenderHorizontalMenu] CRITICAL: .mobile-header element NOT FOUND."); 2472 return; 2473 } 2474 2475 // Apply flexbox styling to position social container at far right 2476 const mobileHeaderContent = mobileHeader.querySelector('.mobile-header-content'); 2477 if (mobileHeaderContent) { 2478 mobileHeaderContent.style.display = 'flex'; 2479 const isSpaceColonyTheme = isSpaceColonySiteTheme(); 2480 const isMobile = window.innerWidth <= 768; 2481 mobileHeaderContent.style.alignItems = isSpaceColonyTheme && !isMobile ? 'flex-end' : (isSpaceColonyTheme && isMobile ? 'flex-start' : 'center'); 2482 mobileHeaderContent.style.justifyContent = isMobile ? (isSpaceColonyTheme ? 'flex-start' : 'center') : (isSpaceColonyTheme ? 'flex-start' : 'space-between'); 2483 if (isSpaceColonyTheme && !isMobile) { 2484 mobileHeaderContent.style.flexGrow = '0'; 2485 mobileHeaderContent.style.width = ''; 2486 } else { 2487 mobileHeaderContent.style.flexGrow = ''; 2488 mobileHeaderContent.style.width = '100%'; 2489 } 2490 2491 // Create a flex container for logo and menu items 2492 const logoMenuContainer = mobileHeaderContent.querySelector('.logo-menu-container'); 2493 if (!logoMenuContainer) { 2494 console.log('[RenderHorizontalMenu] Creating logo-menu wrapper...'); 2495 const logoMenuWrapper = document.createElement('div'); 2496 logoMenuWrapper.className = 'logo-menu-container'; 2497 // Move logo and menu into the wrapper 2498 const logoContainer = mobileHeaderContent.querySelector('.horizontal-header-logo'); 2499 const menuContainer = mobileHeaderContent.querySelector('.horizontal-menu
2499-container'); 2500 2501 console.log('[RenderHorizontalMenu] Found logo container:', !!logoContainer); 2502 console.log('[RenderHorizontalMenu] Found menu container:', !!menuContainer); 2503 2504 if (logoContainer && menuContainer) { 2505 // Insert wrapper before the logo 2506 mobileHeaderContent.insertBefore(logoMenuWrapper, logoContainer); 2507 // Move both containers into the wrapper 2508 logoMenuWrapper.appendChild(logoContainer); 2509 logoMenuWrapper.appendChild(menuContainer); 2510 console.log('[RenderHorizontalMenu] Successfully moved logo and menu into wrapper'); 2511 } else { 2512 console.warn('[RenderHorizontalMenu] Missing logo or menu container, cannot create wrapper'); 2513 } 2514 } else { 2515 console.log('[RenderHorizontalMenu] Logo-menu wrapper already exists'); 2516 } 2517 if (typeof applyHorizontalLogoMenuLayoutStyles === 'function') { 2518 applyHorizontalLogoMenuLayoutStyles(); 2519 } 2520 } 2521 2522 const logoContainer = mobileHeader.querySelector('.horizontal-header-logo'); 2523 if (!logoContainer) { 2524 console.error("[RenderHorizontalMenu] CRITICAL: .horizontal-header-logo container NOT FOUND within .mobile-header."); 2525 return; 2526 } 2527 2528 console.log("[RenderHorizontalMenu] Found .horizontal-header-logo container"); 2529 logoContainer.innerHTML = ''; // Clear existing logo content first 2530 2531 // FIXED: Check if we're on mobile - if so, don't render the horizontal logo 2532 const isMobileView = window.innerWidth <= 768; // Adjust breakpoint as needed 2533 2534 if (isMobileView) { 2535 console.log("[RenderHorizontalMenu] Mobile view detected - skipping horizontal logo rendering"); 2536 // On mobile, the logo will be handled by updateMobileHeaderContent() 2537 } else { 2538 console.log("[RenderHorizontalMenu] Desktop view - rendering horizontal logo"); 2539 2540 // Get the logo element data 2541 let logoElementData = null; 2542 try { 2543 logoElementData = getFirstSidebarElementForHeader(); 2544 console.log('[RenderHorizontalMenu] getFirstSidebarElementForHeader() returned:', JSON.stringify(logoElementData)); 2545 } catch (error) { 2546 console.error('[RenderHorizontalMenu] Error getting sidebar element:', error); 2547 logoElementData = getFirstSidebarElementFallback(); 2548 } 2549 2550 if (logoElementData) { 2551 if (logoElementData.type === 'image' && (logoElementData.imageData || logoElementData.imageUrl)) { 2552 console.log('[RenderHorizontalMenu] Attempting to render IMAGE logo with src:', logoElementData.imageData || logoElementData.imageUrl); 2553 2554 const img = document.createElement('img'); 2555 img.src = logoElementData.imageUrl || logoElementData.imageData; 2556 img.alt = resolveBrandLogoAlt(logoElementData); 2557 img.className = 'sidebar-image'; 2558 2559 // Apply comprehensive styling to ensure visibility 2560 img.style.display = 'block'; 2561 2562 // --- FIX --- 2563 // The line below was removed. It was applying a fixed max-height of 50px, 2564 // overriding your CSS. Removing it allows your stylesheet to take control. 2565 // img.style.maxHeight = '50px'; 2566 2567 img.style.objectFit = 'contain'; 2568 img.style.minWidth = '1px'; 2569 img.style.minHeight = '0'; 2570 2571 let logoDisplayNode = img; 2572 if (logoElementData.colorizeWithSiteColors && 2573 window.SidebarManager && 2574 typeof window.SidebarManager._buildSidebarImageDisplayNode === 'function') { 2575 const buildData = Object.assign({}, logoElementData); 2576 if (!buildData.imageWidth) { 2577 buildData.imageWidth = 'auto'; 2578 } 2579 logoDisplayNode = window.SidebarManager._buildSidebarImageDisplayNode(img, buildData); 2580 } else { 2581 img.style.width = 'auto'; 2582 img.style.visibility = 'visible'; 2583 img.style.opacity = '1'; 2584 } 2585 2586 // Opt-in per-image padding wrapper (no-op when unset; does not alter .horizontal-header-logo CSS) 2587 if (window.SidebarManager && 2588 typeof window.SidebarManager._wrapWithImagePadding === 'function') { 2589 logoDisplayNode = window.SidebarManager._wrapWithImagePadding(logoDisplayNode, logoElementData); 2590 } 2591 2592 // Enhanced event listeners for debugging 2593 img.onload = () => { 2594 console.log('[RenderHorizontalMenu] Image successfully LOADED'); 2595 }; 2596 2597 img.onerror = (error) => { 2598 console.error('[RenderHorizontalMenu] Image FAILED to load:', error); 2599 renderTextLogo(logoContainer, logoElementData.title || 'Logo'); 2600 }; 2601 2602 try { 2603 // Handle linking (similar to sidebar-manager.js) 2604 // Only add links in view mode, not edit mode 2605 // Explicit edit toggle only â Parameters.isInEditor is also true for logged-in admins 2606 const isEditing = document.body.classList.contains('edit-mode-active') || 2607 (typeof window.isInEditMode === 'function' && window.isInEditMode()); 2608 2609 // Debug: Log link properties and element data 2610 console.log('[RenderHorizontalMenu] Full logoElementData:', logoElementData); 2611 console.log('[RenderHorizontalMenu] Image link properties:', { 2612 linkType: logoElementData.linkType, 2613 linkUrl: logoElementData.linkUrl, 2614 linkPageId: logoElementData.linkPageId, 2615 linkTarget: logoElementData.linkTarget, 2616 isEditing: isEditing, 2617 hasLinkType: !!logoElementData.linkType, 2618 linkTypeValue: logoElementData.linkType, 2619 linkTypeNotNone: logoElementData.linkType && logoElementData.linkType !== 'none', 2620 notEditing: !isEditing, 2621 bodyHasEditClass: document.body.classList.contains('edit-mode-active'), 2622 isInEditMode: typeof window.isInEditMode === 'function' ? window.isInEditMode() : 'N/A', 2623 ParametersIsInEditor: window.Parameters ? window.Parameters.isInEditor : 'N/A' 2624 }); 2625 2626 // Check for link - handle undefined, null, empty string, or 'none' 2627 const hasLink = logoElementData.linkType && 2628 logoElementData.linkType !== 'none' && 2629 logoElementData.linkType !== '' && 2630 !isEditing; 2631 2632 console.log('[RenderHorizontalMenu] Has link?', hasLink, 'linkType:', logoElementData.linkType); 2633 2634 if (hasLink) { 2635 const link = document.createElement('a'); 2636 link.target = logoElementData.linkTarget || '_self'; 2637 let finalLinkHref = '#'; 2638 let isValidLink = false;
2639 2640 if (logoElementData.linkType === 'external' && logoElementData.linkUrl) { 2641 finalLinkHref = logoElementData.linkUrl; 2642 if (link.target === '_blank') { 2643 link.rel = 'noopener noreferrer'; 2644 } 2645 isValidLink = true; 2646 } else if (logoElementData.linkType === 'internal' && logoElementData.linkPageId) { 2647 const allGalleries = window.galleries || (typeof galleries !== 'undefined' ? galleries : []); 2648 const linkPageIdNum = parseInt(logoElementData.linkPageId); 2649 console.log('[RenderHorizontalMenu] Looking for internal page:', { 2650 linkPageId: logoElementData.linkPageId, 2651 linkPageIdNum: linkPageIdNum, 2652 allGalleriesLength: allGalleries ? allGalleries.length : 0, 2653 galleryIds: allGalleries ? allGalleries.map(g => ({ id: g.id, title: g.title })).slice(0, 5) : [] 2654 }); 2655 2656 const targetPage = allGalleries.find(g => g.id === linkPageIdNum); 2657 2658 console.log('[RenderHorizontalMenu] Target page found:', targetPage ? { id: targetPage.id, title: targetPage.title, slug: targetPage.slug, url: targetPage.url } : null); 2659 2660 if (targetPage) { 2661 // Handle preview mode URLs 2662 const isPreviewMode = window.location.hostname === 'preview.neonsky.app'; 2663 console.log('[RenderHorizontalMenu] Preview mode:', isPreviewMode); 2664 2665 if (isPreviewMode) { 2666 const pathParts = window.location.pathname.split('/').filter(Boolean); 2667 const siteGuid = pathParts[0]; 2668 console.log('[RenderHorizontalMenu] Preview mode path parts:', pathParts, 'siteGuid:', siteGuid, 'targetPage.slug:', targetPage.slug); 2669 2670 if (siteGuid && targetPage.slug) { 2671 finalLinkHref = '/' + siteGuid + '/' + targetPage.slug; 2672 isValidLink = true; 2673 console.log('[RenderHorizontalMenu] Set preview href:', finalLinkHref); 2674 } else if (targetPage.url && targetPage.url.startsWith('/')) { 2675 finalLinkHref = targetPage.url; 2676 isValidLink = true; 2677 console.log('[RenderHorizontalMenu] Set preview href from url:', finalLinkHref); 2678 } 2679 } else { 2680 if (targetPage.slug) { 2681 finalLinkHref = '/' + targetPage.slug; 2682 isValidLink = true; 2683 console.log('[RenderHorizontalMenu] Set non-preview href:', finalLinkHref); 2684 } else if (targetPage.url && targetPage.url.startsWith('/')) { 2685 finalLinkHref = targetPage.url; 2686 isValidLink = true; 2687 console.log('[RenderHorizontalMenu] Set non-preview href from url:', finalLinkHref); 2688 } 2689 } 2690 } else { 2691 console.warn('[RenderHorizontalMenu] Target page not found for linkPageId:', logoElementData.linkPageId); 2692 } 2693 } 2694 2695 if (isValidLink) { 2696 link.href = finalLinkHref; 2697 // Ensure link is clickable 2698 link.style.pointerEvents = 'auto'; 2699 link.style.cursor = 'pointer'; 2700 link.style.textDecoration = 'none'; 2701 link.style.display = 'flex'; 2702 link.style.alignItems = 'center'; 2703 link.style.height = 'auto'; 2704 link.style.maxHeight = '100%'; 2705 link.style.minHeight = '0'; 2706 link.style.lineHeight = '0'; 2707 console.log('[RenderHorizontalMenu] Link created with href:', finalLinkHref, 'target:', link.target, 'styles applied'); 2708 2709 // For internal links, add click handler to use site navigation 2710 if (logoElementData.linkType === 'internal' && logoElementData.linkPageId) { 2711 // Store linkPageId in a variable that won't be affected by closure 2712 const linkPageId = logoElementData.linkPageId; 2713 const linkHref = finalLinkHref; 2714 2715 // Add click handler with both capture and bubble phases to ensure it fires 2716 const clickHandler = (e) => { 2717 console.log('[RenderHorizontalMenu] Link clicked! linkPageId:', linkPageId, 'event:', e); 2718 e.preventDefault(); 2719 e.stopPropagation(); 2720 e.stopImmediatePropagation(); 2721 2722 const allGalleries = window.galleries || (typeof galleries !== 'undefined' ? galleries : []); 2723 const linkPageIdNum = parseInt(linkPageId); 2724 const targetPage = allGalleries.find(g => g.id === linkPageIdNum); 2725 2726 console.log('[RenderHorizontalMenu] Click handler - targetPage found:', targetPage ? { id: targetPage.id, title: targetPage.title, isPage: targetPage.isPage } : null); 2727 console.log('[RenderHorizontalMenu] Click handler - loadPage function:', typeof window.loadPage, 'loadGallery function:', typeof window.loadGallery); 2728 2729 if (targetPage) { 2730 if (isPageMenuItem(targetPage) && typeof window.loadPage === 'function') { 2731 console.log('[RenderHorizontalMenu] Calling loadPage with id:', targetPage.id); 2732 window.loadPage(targetPage.id); 2733 } else if (typeof window.loadGallery === 'function') { 2734 console.log('[RenderHorizontalMenu] Calling loadGallery with id:', targetPage.id); 2735 window.loadGallery(targetPage.id); 2736 } else { 2737 // Fallback to standard navigation 2738 console.log('[RenderHorizontalMenu] Fallback to window.location.href:', linkHref); 2739 window.location.href = linkHref; 2740 } 2741 } else { 2742 // Fallback to standard navigation if page not found 2743 console.warn('[RenderHorizontalMenu] Target page not found in click handler, using fallback href:', linkHref); 2744 window.location.href = linkHref; 2745 } 2746 2747 return false;
2748 }; 2749 2750 // Add handler in capture phase to catch event early 2751 link.addEventListener('click', clickHandler, true); 2752 // Also add in bubble phase as backup 2753 link.addEventListener('click', clickHandler, false); 2754 2755 // Also add mousedown as backup in case click is being prevented 2756 link.addEventListener('mousedown', (e) => { 2757 if (e.button === 0) { // Left click only 2758 console.log('[RenderHorizontalMenu] MouseDown detected on link, triggering click handler'); 2759 clickHandler(e); 2760 } 2761 }); 2762 2763 console.log('[RenderHorizontalMenu] Click handlers attached to link'); 2764 } 2765 2766 link.appendChild(logoDisplayNode); 2767 logoContainer.appendChild(link); 2768 console.log('[RenderHorizontalMenu] SUCCESSFULLY appended linked image to logoContainer. Link element:', link, 'href:', link.href, 'parent:', logoContainer); 2769 2770 // Verify link is in DOM and clickable 2771 setTimeout(() => { 2772 const verifyLink = logoContainer.querySelector('a'); 2773 if (verifyLink) { 2774 console.log('[RenderHorizontalMenu] Link verification - Found in DOM:', { 2775 href: verifyLink.href, 2776 pointerEvents: window.getComputedStyle(verifyLink).pointerEvents, 2777 cursor: window.getComputedStyle(verifyLink).cursor, 2778 zIndex: window.getComputedStyle(verifyLink).zIndex, 2779 position: window.getComputedStyle(verifyLink).position 2780 }); 2781 } else { 2782 console.warn('[RenderHorizontalMenu] Link verification - Link NOT found in DOM!'); 2783 } 2784 }, 100); 2785 } else { 2786 console.warn('[RenderHorizontalMenu] Link is not valid, appending image without link. isValidLink:', isValidLink); 2787 logoContainer.appendChild(logoDisplayNode); 2788 console.log('[RenderHorizontalMenu] SUCCESSFULLY appended image to logoContainer.'); 2789 } 2790 } else { 2791 logoContainer.appendChild(logoDisplayNode); 2792 console.log('[RenderHorizontalMenu] SUCCESSFULLY appended image to logoContainer.'); 2793 } 2794 } catch (e) { 2795 console.error('[RenderHorizontalMenu] Error during logoContainer.appendChild(img):', e); 2796 renderTextLogo(logoContainer, logoElementData.title || 'Logo'); 2797 } 2798 2799 } else if (logoElementData.type === 'text' && logoElementData.textContent) { 2800 console.log('[RenderHorizontalMenu] Attempting to render TEXT logo:', logoElementData.textContent); 2801 renderTextLogo(logoContainer, logoElementData.textContent); 2802 2803 } else { 2804 console.log('[RenderHorizontalMenu] Using title as fallback logo'); 2805 renderTextLogo(logoContainer, logoElementData.title || 'Menu'); 2806 } 2807 } else { 2808 console.log("[RenderHorizontalMenu] No logoElementData available. Using default text."); 2809 renderTextLogo(logoContainer, 'Menu'); 2810 } 2811 } 2812 2813 // Render menu items (only on desktop, not on mobile) 2814 const menuContainer = mobileHeader.querySelector('.horizontal-menu-container'); 2815 if (!menuContainer) { 2816 console.error("[RenderHorizontalMenu] .horizontal-menu-container NOT FOUND within .mobile-header."); 2817 } else { 2818 menuContainer.innerHTML = ''; 2819 2820 // Apply alignment class from settings 2821 if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings) { 2822 const rawAlign = window.MenuStyleCustomizer.settings.horizontalMenuAlignment || 'left'; 2823 const alignment = ['left', 'center', 'right'].indexOf(rawAlign) >= 0 ? rawAlign : 'left'; 2824 menuContainer.classList.remove('menu-align-left', 'menu-align-right', 'menu-align-center'); 2825 menuContainer.classList.add('menu-align-' + alignment); 2826 console.log('[RenderHorizontalMenu] Applied alignment class: menu-align-'
2826+ alignment); 2827 } 2828 2829 // Only populate menu items on desktop, not on mobile 2830 if (!isMobileView) { 2831 let galleryTree = []; 2832 if (typeof createGalleryTree === 'function' && typeof galleries !== 'undefined') { 2833 galleryTree = createGalleryTree(galleries); 2834 } else { 2835 console.warn("[RenderHorizontalMenu] createGalleryTree function or galleries array is undefined."); 2836 } 2837 2838 galleryTree.forEach(item => { 2839 const menuItemElement = createHorizontalMenuItem(item); 2840 if (menuItemElement) { 2841 menuContainer.appendChild(menuItemElement); 2842 } 2843 }); 2844 if (typeof syncSeoNavA11y === 'function') syncSeoNavA11y(); 2845 } else { 2846 console.log("[RenderHorizontalMenu] Mobile view - skipping horizontal menu item population"); 2847 } 2848 } 2849 2850 // Render social icons (only on desktop, not on mobile) 2851 let socialContainer = mobileHeader.querySelector('.horizontal-social-container'); 2852 if (!socialContainer) { 2853 // Fallback: create the social container if it doesn't exist 2854 console.log("[RenderHorizontalMenu] .horizontal-social-container not found, creating it..."); 2855 const mobileHeaderContent = mobileHeader.querySelector('.mobile-header-content'); 2856 if (mobileHeaderContent) { 2857 socialContainer = document.createElement('div'); 2858 socialContainer.className = 'horizontal-social-container'; 2859 mobileHeaderContent.appendChild(socialContainer); 2860 console.log("[RenderHorizontalMenu] Created .horizontal-social-container"); 2861 } else { 2862 console.error("[RenderHorizontalMenu] .mobile-header-content not found, cannot create social container"); 2863 } 2864 } 2865 2866 if (socialContainer) { 2867 socialContainer.innerHTML = ''; 2868 2869 // Only populate social icons on desktop, not on mobile 2870 if (!isMobileView) { 2871 const socialElement = getFirstSocialElementForHeader(); 2872 if (socialElement && socialElement.type === 'social') { 2873 console.log('[RenderHorizontalMenu] Found social element, rendering social icons'); 2874 renderSocialIcons(socialContainer, socialElement); 2875 } else { 2876 console.log('[RenderHorizontalMenu] No social element found or element is not social type'); 2877 // Container remains empty - no impact on layout 2878 } 2879 } else { 2880 console.log("[RenderHorizontalMenu] Mobile view - skipping social icons population"); 2881 } 2882 } 2883 2884 2885 2886 if (typeof updateActiveStatesHorizontal === 'function') { 2887 updateActiveStatesHorizontal(); 2888 } 2889 2890 if (!isMobileView && mobileHeaderContent) { 2891 mobileHeaderContent.querySelectorAll('.mobile-header-text, .mobile-header-menu-title, .mobile-header-title').forEach(function(el) { 2892 if (!el.closest('.horizontal-header-logo')) { 2893 el.remove(); 2894 } 2895 }); 2896 } 2897 2898setTimeout(() => { 2899 ensureSidebarContentForMobile(); 2900}, 100); 2901 2902 if (typeof syncContentMarginToHorizontalHeader === 'function') { 2903 setTimeout(function() { syncContentMarginToHorizontalHeader(); }, 200); 2904 } 2905 2906 console.log("[RenderHorizontalMenu] Function COMPLETED."); 2907} 2908 2909/** 2910 * Renders social icons in the horizontal header container. 2911 * @param {HTMLElement} container - The container to render social icons in 2912 * @param {object} socialElement - The social element data from sidebar 2913 */
2914function renderSocialIcons(container, socialElement) { 2915 console.log('[RenderSocialIcons] Rendering social icons:', socialElement); 2916 2917 try { 2918 // Safety check: ensure we have a valid social element 2919 if (!socialElement || socialElement.type !== 'social') { 2920 console.log('[RenderSocialIcons] Invalid social element - skipping rendering'); 2921 return; 2922 } 2923 2924 /* Placeholders without URLs still need menu color (CSS also sets this). */ 2925 container.style.color = 'var(--menu-color, #333)'; 2926 2927 // Get icon size with proper fallbacks 2928 let iconSize = 30; 2929 if (typeof socialElement.socialIconSize === 'number' && !isNaN(socialElement.socialIconSize)) { 2930 iconSize = socialElement.socialIconSize; 2931 } else if (typeof socialElement.socialIconSize === 'string' && !isNaN(parseInt(socialElement.socialIconSize))) { 2932 iconSize = parseInt(socialElement.socialIconSize); 2933 } 2934 2935 // Render icons directly from element data 2936 if (socialElement.socialIcons && Array.isArray(socialElement.socialIcons)) { 2937 socialElement.socialIcons.forEach(icon => { 2938 // Create icon wrapper (color via CSS --menu-color so placeholders without URLs stay visible) 2939 const iconWrapper = document.createElement('div'); 2940 iconWrapper.className = 'social-icon-wrapper'; 2941 iconWrapper.style.cssText = 'width: ' + iconSize + 'px !important; height: ' + iconSize + 'px !important; display: flex !important; align-items: center !important; justify-content: center !important; color: inherit !important;'; 2942 2943 const applySvgSizing = (host) => { 2944 const svg = host.querySelector('svg'); 2945 if (!svg) return; 2946 svg.style.width = '100%'; 2947 svg.style.height = '100%'; 2948 svg.setAttribute('width', '100%'); 2949 svg.setAttribute('height', '100%'); 2950 svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); 2951 svg.setAttribute('aria-hidden', 'true'); 2952 svg.setAttribute('focusable', 'false'); 2953 svg.style.color = 'inherit'; 2954 svg.style.fill = 'currentColor'; 2955 svg.style.stroke = 'currentColor'; 2956 }; 2957 2958 const hasUrl = !!(icon.url && String(icon.url).trim()); 2959 2960 // Add link if URL exists; otherwise still render the glyph (placeholder icons) 2961 if (hasUrl) { 2962 const link = document.createElement('a'); 2963 link.href = icon.url; 2964 link.target = '_blank'; 2965 link.rel = 'noopener noreferrer'; 2966 link.classList.add('horizontal-social-link'); 2967 link.style.cssText = 'display: flex !important; align-items: center !important; justify-content: center !important; width: 100% !important; height: 100% !important; color: inherit !important; text-decoration: none !important;'; 2968 const socialLabel = (icon.alt && String(icon.alt).trim()) 2969 || (window.SidebarManager && typeof window.SidebarManager._socialIconTypeLabel === 'function' 2970 ? window.SidebarManager._socialIconTypeLabel(icon.type) 2971 : (icon.type && icon.type !== 'custom' ? String(icon.type).replace(/_/g, ' ') : '')) 2972 || 'Social link'; 2973 const socialLabelPretty = socialLabel.charAt(0).toUpperCase() + socialLabel.slice(1); 2974 link.setAttribute('aria-label', socialLabelPretty + ' (opens in new tab)'); 2975 2976 if (icon.type === 'custom' && icon.iconUrl) { 2977 // Render uploaded image 2978 const img = document.createElement('img'); 2979 img.src = icon.iconUrl; 2980 img.alt = ''; 2981 img.setAttribute('aria-hidden', 'true'); 2982 img.style.width = '100%'; 2983 img.style.height = '100%'; 2984 img.style.objectFit = 'contain'; 2985 link.appendChild(img); 2986 } else { 2987 // Add SVG - use SidebarManager's method if available, otherwise create basic SVG 2988 if (window.SidebarManager && typeof window.SidebarManager._getSocialIconSvg === 'function') { 2989 link.innerHTML = window.SidebarManager._getSocialIconSvg(icon.type); 2990 } else { 2991 // Fallback: create a simple SVG placeholder 2992 link.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>'; 2993 } 2994 applySvgSizing(link); 2995 } 2996 iconWrapper.appendChild(link); 2997 } else {
2998 // Placeholder: no URL â still paint with menu color via container inheritance 2999 iconWrapper.setAttribute('aria-hidden', 'true'); 3000 if (icon.type === 'custom' && icon.iconUrl) { 3001 const img = document.createElement('img'); 3002 img.src = icon.iconUrl; 3003 img.alt = ''; 3004 img.style.width = '100%'; 3005 img.style.height = '100%'; 3006 img.style.objectFit = 'contain'; 3007 iconWrapper.appendChild(img); 3008 } else { 3009 if (window.SidebarManager && typeof window.SidebarManager._getSocialIconSvg === 'function') { 3010 iconWrapper.innerHTML = window.SidebarManager._getSocialIconSvg(icon.type); 3011 } else { 3012 iconWrapper.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>'; 3013 } 3014 applySvgSizing(iconWrapper); 3015 } 3016 } 3017 3018 container.appendChild(iconWrapper); 3019 console.log('[RenderSocialIcons] Rendered social icon:', icon.type, icon.url); 3020 }); 3021 } else { 3022 console.log('[RenderSocialIcons] No social icons found in element'); 3023 } 3024 } catch (error) { 3025 console.error('[RenderSocialIcons] Error rendering social icons:', error); 3026 } 3027} 3028 3029/** 3030 * Gets the appropriate icon text or class for a social platform. 3031 * @param {string} platform - The social platform name 3032 * @returns {string} The icon text or class 3033 */ 3034function getSocialIconText(platform) { 3035 const iconMap = { 3036 'facebook': 'f', 3037 'twitter': 't', 3038 'instagram': 'i', 3039 'linkedin': 'in', 3040 'youtube': 'yt', 3041 'pinterest': 'p', 3042 'tiktok': 'tt', 3043 'snapchat': 'sc', 3044 'whatsapp': 'wa', 3045 'telegram': 'tg', 3046 'discord': 'd', 3047 'github': 'gh', 3048 'email': '@', 3049 'phone': 'ð', 3050 'website': 'ð' 3051 }; 3052 3053 return iconMap[platform.toLowerCase()] || 'â¢'; 3054} 3055 3056 3057 3058 3059/** 3060 * Creates a single top-level horizontal menu item and its submenu if it has children. 3061 * (UPDATED to handle async loadGallery and pass clickedItemId to a delayed updateActiveStatesHorizontal) 3062 * @param {object} galleryItem - The gallery item data. 3063 * @returns {HTMLElement | null} The created menu item element, or null if not rendered. 3064 */ 3065function createHorizontalMenuItem(galleryItem) { 3066 if (galleryItem.visible === false || galleryItem.isSpacer) { 3067 return null; 3068 } 3069 3070 const menuItem = document.createElement('div'); 3071 menuItem.className = 'horizontal-menu-item'; 3072 menuItem.dataset.id = String(galleryItem.id); 3073 menuItem.setAttribute('data-visible', galleryItem.visible !== false ? 'true' : 'false'); 3074 3075 const hasVisibleChildren = !!(galleryItem.children && galleryItem.children.filter(c => c.visible !== false && !c.isSpacer).length); 3076 const isNavigableParent = isNavigableMenuParent(galleryItem); 3077 let titleEl; 3078 if (hasVisibleChildren && !isNavigableParent) { 3079 titleEl = document.createElement('span'); 3080 titleEl.className = 'horizontal-menu-label'; 3081 titleEl.textContent = galleryItem.title; 3082 menuItem.setAttribute('aria-haspopup', 'true'); 3083 menuItem.setAttribute('aria-expanded', 'false'); 3084 } else { 3085 titleEl = document.createElement('a'); 3086 titleEl.className = 'horizontal-menu-label'; 3087 titleEl.href = typeof getMenuItemHref === 'function' ? getMenuItemHref(galleryItem) : '#'; 3088 titleEl.textContent = galleryItem.title; 3089 if (galleryItem.isExternal) { 3090 titleEl.target = '_blank'; 3091 titleEl.rel = 'noopener noreferrer'; 3092 } else { 3093 titleEl.addEventListener('click', (e) => { e.preventDefault(); }); 3094 } 3095 } 3096 menuItem.appendChild(titleEl); 3097 3098 // Ensure activeGalleryId is treated as a number for comparison during initial render 3099 const currentGlobalActiveId = parseInt(String(window.activeGalleryId || activeGalleryId)); 3100 if (galleryItem.id === currentGlobalActiveId) { 3101 menuItem.classList.add('active'); 3102 } 3103 3104 let leaveTimer; // For mouseleave timeout to close dropdown 3105 3106 if (galleryItem.children && galleryItem.children.length > 0) { 3107 const visibleChildren = galleryItem.children.filter(child => child.visible !== false && !child.isSpacer); 3108 3109 if (visibleChildren.length > 0) { 3110 menuItem.classList.add('has-children'); 3111 if (isNavigableParent) { 3112 menuItem.setAttribute('aria-haspopup', 'true'); 3113 menuItem.setAttribute('aria-expanded', 'false'); 3114 } 3115 3116 const toggleIconContainer = document.createElement(isNavigableParent ? 'button' : 'span'); 3117 toggleIconContainer.className = 'submenu-toggle-horizontal'; 3118 if (isNavigableParent) { 3119 toggleIconContainer.type = 'button'; 3120 toggleIconContainer.setAttribute('aria-label', 'Show submenu for ' + galleryItem.title); 3121 toggleIconContainer.setAttribute('aria-expanded', 'false'); 3122 toggleIconContainer.addEventListener('click', function(event) { 3123 event.preventDefault(); 3124 event.stopPropagation(); 3125 const willExpand = !menuItem.classList.contains('expanded'); 3126 menuItem.classList.toggle('expanded', willExpand); 3127 menuItem.setAttribute('aria-expanded', willExpand ? 'true' : 'false'); 3128 toggleIconContainer.setAttribute('aria-expanded', willExpand ? 'true' : 'false'); 3129 const icon = toggleIconContainer.querySelector('.icon.toggle-icon'); 3130 if (icon) icon.classList.toggle('rotated', willExpand); 3131 }); 3132 } 3133 const svgIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); 3134 svgIcon.setAttribute("class", "icon toggle-icon"); 3135 svgIcon.setAttribute("viewBox", "0 0 24 24"); 3136 svgIcon.setAttribute("fill", "none"); 3137 svgIcon.setAttribute("stroke", "currentColor"); 3138 svgIcon.setAttribute("stroke-width", "2"); 3139 const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); 3140 polyline.setAttribute("points", "9 6 15 12 9 18"); // Right-pointing arrow
3141 svgIcon.appendChild(polyline); 3142 toggleIconContainer.appendChild(svgIcon); 3143 menuItem.appendChild(toggleIconContainer); 3144 3145 const subMenu = document.createElement('div'); 3146 subMenu.className = 'horizontal-dropdown'; 3147 const subMenuList = document.createElement('ul'); 3148 subMenuList.className = 'horizontal-dropdown-list'; 3149 3150 visibleChildren.forEach((childItem) => { 3151 const subMenuItemElement = createHorizontalSubMenuItem(childItem); // Recursive call 3152 if (subMenuItemElement) { 3153 subMenuList.appendChild(subMenuItemElement); 3154 } 3155 }); 3156 3157 if (subMenuList.hasChildNodes()) { 3158 subMenu.appendChild(subMenuList); 3159 menuItem.appendChild(subMenu); 3160 3161 menuItem.addEventListener('mouseenter', function() { 3162 clearTimeout(leaveTimer); 3163 document.querySelectorAll('.horizontal-menu-item.expanded').forEach((openItem) => { 3164 if (openItem !== this) { 3165 openItem.classList.remove('expanded'); 3166 openItem.setAttribute('aria-expanded', 'false'); 3167 const otherIcon = openItem.querySelector('.icon.toggle-icon'); 3168 if (otherIcon) otherIcon.classList.remove('rotated'); 3169 } 3170 }); 3171 this.classList.add('expanded'); 3172 this.setAttribute('aria-expanded', 'true'); 3173 if (isNavigableParent) toggleIconContainer.setAttribute('aria-expanded', 'true'); 3174 const currentIcon = this.querySelector('.icon.toggle-icon'); 3175 if (currentIcon) currentIcon.classList.add('rotated'); 3176 }); 3177 3178 menuItem.addEventListener('mouseleave', function(event) { 3179 // Check if we're moving to the dropdown or a flyout 3180 const relatedTarget = event.relatedTarget; 3181 const isMovingToDropdown = relatedTarget && ( 3182 relatedTarget.closest('.horizontal-dropdown') || 3183 relatedTarget.classList.contains('horizontal-submenu-item') || 3184 relatedTarget.closest('.horizontal-submenu-item') 3185 ); 3186 const isMovingToFlyout = relatedTarget && ( 3187 relatedTarget.classList.contains('horizontal-nested-dropdown') || 3188 relatedTarget.closest('.horizontal-nested-dropdown') 3189 ); 3190 3191 if (!isMovingToDropdown && !isMovingToFlyout) { 3192 console.log('Main menu item mouseleave - starting timer'); 3193 leaveTimer = setTimeout(() => { 3194 // Check if there's an active flyout - if so, keep parent open 3195 // Use the same multiple detection methods as the submenu handler 3196 const activeFlyout = document.querySelector('.horizontal-nested-dropdown[style*="display: block"]') || 3197 document.querySelector('.horizontal-nested-dropdown[style*="visibility: visible"]') || 3198 document.querySelector('.horizontal-nested-dropdown:not([style*="display: none"])'); 3199 console.log('Main menu timer fired - active flyout:', activeFlyout); 3200 if (!activeFlyout) { 3201 console.log('Main menu closing dropdown - no active flyout'); 3202 this.classList.remove('expanded'); 3203 this.setAttribute('aria-expanded', 'false'); 3204 if (isNavigableParent) toggleIconContainer.setAttribute('aria-expanded', 'false'); 3205 const currentIcon = this.querySelector('.icon.toggle-icon'); 3206 if (currentIcon) currentIcon.classList.remove('rotated'); 3207 } else { 3208 console.log('Main menu keeping dropdown open - flyout is active'); 3209 } 3210 }, 200); 3211 } else { 3212 console.log('Main menu item mouseleave - not starting timer (moving to dropdown or flyout)'); 3213 } 3214 }); 3215 3216 subMenu.addEventListener('mouseenter', function() { 3217 const parentMenuItem = this.closest('.horizontal-menu
3217-item'); 3218 if (parentMenuItem && parentMenuItem.leaveTimer) { 3219 clearTimeout(parentMenuItem.leaveTimer); 3220 parentMenuItem.leaveTimer = null; 3221 } 3222 }); 3223 3224 subMenu.addEventListener('mouseleave', function(event) { 3225 const parentMenuItem = this.closest('.horizontal-menu-item'); 3226 if (parentMenuItem) { 3227 // Check if we're moving to another element within the same dropdown 3228 const relatedTarget = event.relatedTarget; 3229 console.log('SubMenu mouseleave - relatedTarget:', relatedTarget); 3230 console.log('SubMenu mouseleave - relatedTarget classes:', relatedTarget ? relatedTarget.className : 'null'); 3231 3232 // Check if we're moving to another element within the same dropdown 3233 const currentDropdown = this.closest('.horizontal-dropdown') || this.closest('.horizontal-submenu')?.closest('.horizontal-dropdown'); 3234 console.log('Current dropdown found:', !!currentDropdown); 3235 console.log('Current dropdown element:', currentDropdown); 3236 console.log('RelatedTarget:', relatedTarget); 3237 console.log('RelatedTarget closest dropdown:', relatedTarget ? relatedTarget.closest('.horizontal-dropdown') : 'null'); 3238 3239 // Check if relatedTarget is within the same dropdown container 3240 const isMovingWithinDropdown = relatedTarget && currentDropdown && ( 3241 relatedTarget.closest('.horizontal-dropdown') === currentDropdown || 3242 relatedTarget.classList.contains('horizontal-submenu-item') || 3243 relatedTarget.closest('.horizontal-submenu-item') || 3244 currentDropdown.contains(relatedTarget) 3245 ); 3246 3247 // Alternative approach: check if relatedTarget is within the same parent menu item 3248 const parentMenuContainer = this.closest('.horizontal-menu-item'); 3249 const isMovingWithinParent = relatedTarget && parentMenuContainer && parentMenuContainer.contains(relatedTarget); 3250 console.log('Is moving within parent menu item:', isMovingWithinParent); 3251 console.log('Current dropdown contains relatedTarget:', currentDropdown ? currentDropdown.contains(relatedTarget) : 'no dropdown'); 3252 console.log('RelatedTarget closest dropdown === currentDropdown:', relatedTarget && currentDropdown ? relatedTarget.closest('.horizontal-dropdown') === currentDropdown : 'null'); 3253 3254 // Also check if mouse is still within the dropdown bounds (in case relatedTarget is null or outside) 3255 // Get the parent menu item's dropdown container 3256 const parentMenuItem = this.closest('.horizontal-menu-item'); 3257 console.log('Parent menu item found:', !!parentMenuItem); 3258 const dropdownContainer = parentMenuItem ? parentMenuItem.querySelector('.horizontal-dropdown') : null; 3259 console.log('Dropdown container found:', !!dropdownContainer); 3260 if (dropdownContainer) { 3261 console.log('Dropdown container classes:', dropdownContainer.className); 3262 console.log('Dropdown container display:', window.getComputedStyle(dropdownContainer).display); 3263 console.log('Dropdown container position:', window.getComputedStyle(dropdownContainer).position); 3264 } 3265 3266 // Get bounds from the visible dropdown (not the hidden one) 3267 let dropdownRect = { left: 0, right: 0, top: 0, bottom: 0 }; 3268 if (dropdownContainer) { 3269 const computedStyle = window.getComputedStyle(dropdownContainer); 3270 if (computedStyle.display !== 'none') { 3271 dropdownRect = dropdownContainer.getBoundingClientRect(); 3272 } else { 3273 // If dropdown is hidden, try to get bounds from the parent menu item 3274 const parentRect = parentMenuItem.getBoundingClientRect(); 3275 dropdownRect = parentRect; 3276 } 3277 } 3278 const mouseX = event.clientX; 3279 const mouseY = event.clientY; 3280 const isMouseInDropdownBounds = mouseX >= dropdownRect.left && mouseX <= dropdownRect.right && 3281 mouseY >= dropdownRect.top && mouseY <= dropdownRect.bottom; 3282
3283 console.log('Dropdown bounds:', { 3284 left: dropdownRect.left, 3285 right: dropdownRect.right, 3286 top: dropdownRect.top, 3287 bottom: dropdownRect.bottom, 3288 mouseX, 3289 mouseY, 3290 mouseInBounds: mouseX >= dropdownRect.left && mouseX <= dropdownRect.right && mouseY >= dropdownRect.top && mouseY <= dropdownRect.bottom 3291 }); 3292 3293 // If there's an active flyout, always keep the parent dropdown open 3294 // Check multiple ways to detect active flyouts 3295 const activeFlyout = document.querySelector('.horizontal-nested-dropdown[style*="display: block"]') || 3296 document.querySelector('.horizontal-nested-dropdown[style*="visibility: visible"]') || 3297 document.querySelector('.horizontal-nested-dropdown:not([style*="display: none"])'); 3298 console.log('Active flyout found:', !!activeFlyout); 3299 if (activeFlyout) { 3300 console.log('Active flyout display style:', activeFlyout.style.display); 3301 console.log('Active flyout computed display:', window.getComputedStyle(activeFlyout).display); 3302 console.log('Active flyout visibility style:', activeFlyout.style.visibility); 3303 } 3304 const hasActiveFlyout = !!activeFlyout; 3305 3306 // If the dropdown is hidden (display: none), we can't get accurate bounds 3307 // So let's use a simpler approach - if we're moving to another submenu item, keep open 3308 const isMovingToSubmenuItem = relatedTarget && ( 3309 relatedTarget.classList.contains('horizontal-submenu-item') || 3310 relatedTarget.closest('.horizontal-submenu-item') 3311 ); 3312 console.log('Is moving to submenu item:', isMovingToSubmenuItem); 3313 console.log('RelatedTarget is submenu item:', relatedTarget ? relatedTarget.classList.contains('horizontal-submenu-item') : 'null'); 3314 console.log('RelatedTarget has submenu item ancestor:', relatedTarget ? !!relatedTarget.closest('.horizontal-submenu-item') : 'null'); 3315 3316 // If we're moving to another submenu item within the same dropdown, al
3316ways keep open 3317 // OR if there's an active flyout (regardless of where the mouse is), keep open 3318 // Simple approach: if mouse is within dropdown bounds OR there's an active flyout, keep open 3319 const shouldKeepOpen = isMouseInDropdownBounds || hasActiveFlyout || isMovingWithinDropdown || isMovingToSubmenuItem; 3320 3321 console.log('Is moving within dropdown:', isMovingWithinDropdown); 3322 console.log('Is mouse in dropdown bounds:', isMouseInDropdownBounds); 3323 console.log('Has active flyout:', hasActiveFlyout); 3324 console.log('Should keep open:', shouldKeepOpen); 3325 3326 if (!shouldKeepOpen) { 3327 console.log('SubMenu mouseleave - starting timer (moving outside dropdown)'); 3328 parentMenuItem.leaveTimer = setTimeout(() => { 3329 // Check if there's an active flyout - if so, keep parent open 3330 const activeFlyout = document.querySelector('.horizontal-nested-dropdown[style*="display: block"]'); 3331 console.log('Timer fired - active flyout:', activeFlyout); 3332 if (!activeFlyout) { 3333 console.log('Closing parent dropdown - no active flyout'); 3334 parentMenuItem.classList.remove('expanded'); 3335 const currentIcon = parentMenuItem.querySelector('.icon.toggle-icon'); 3336 if (currentIcon) currentIcon.classList.remove('rotated'); 3337 } else { 3338 console.log('Keeping parent dropdown open - flyout is active'); 3339 } 3340 }, 200); 3341 } else { 3342 console.log('SubMenu mouseleave - not starting timer (moving within dropdown)'); 3343 } 3344 } 3345 }); 3346 } else { 3347 menuItem.classList.remove('has-children'); 3348 if (toggleIconContainer.parentNode) { 3349 toggleIconContainer.remove(); 3350 } 3351 } 3352 } 3353 } 3354 3355 // Click listener for items that are direct navigation links (not folders with children) 3356 if (!menuItem.classList.contains('has-children') || isNavigableParent) { 3357 menuItem.addEventListener('click', async (event) => { 3358 if (isNavigableParent && hasVisibleChildren && !titleEl.contains(event.target)) { 3359 return; 3360 } 3361 event.stopPropagation(); 3362 const clickedItemId = galleryItem.id; // Capture the ID of the clicked item 3363 3364 // Check if it's an external URL first 3365 if (galleryItem.isExternal && galleryItem.url) { 3366 window.open(galleryItem.url, '_blank', 'noopener,noreferrer'); 3367 return; 3368 } 3369 3370 if (isPageMenuItem(galleryItem)) { 3371 if (typeof loadPage === 'function') { 3372 loadPage(clickedItemId, event); // loadPage is synchronous 3373 if (typeof updateActiveStatesHorizontal === 'function') { 3374 console.log("Click Handler (Page): Calling updateActiveStatesHorizontal for ID:", clickedItemId); 3375 // Use a minimal delay even for sync operations if page rendering has its own async microtasks 3376 setTimeout(() => updateActiveStatesHorizontal(clickedItemId), 50); 3377 } 3378 } 3379 } else { // It's a gallery 3380 if (typeof loadGallery === 'function') { 3381 console.log("Click Handler (Gallery): Awaiting loadGallery for ID:", clickedItemId); 3382 await loadGallery(clickedItemId, event); // Await the asynchronous loadGallery 3383 console.log("Click Handler (Gallery): loadGallery completed for ID:", clickedItemId, ". Calling updateActiveStatesHorizontal with delay."); 3384 if (typeof updateActiveStatesHorizontal === 'function') { 3385 setTimeout(() => { 3386 console.log("[Delayed Update from Click] Calling updateActiveStatesHorizontal for gallery ID:", clickedItemId); 3387 updateActiveStatesHorizontal(clickedItemId); // Pass the captured clickedItemId 3388 }, 100); // Delay to allow gallery system to settle 3389 } 3390 } 3391 } 3392
3393 // Close any other open dropdowns and flyouts 3394 document.querySelectorAll('.horizontal-menu-item.expanded').forEach((openItem) => { 3395 // Don't close the parent if a child within its dropdown was clicked (already handled by submenu click) 3396 if (openItem !== menuItem.closest('.horizontal-menu-item.expanded')) { 3397 openItem.classList.remove('expanded'); 3398 openItem.setAttribute('aria-expanded', 'false'); 3399 const icon = openItem.querySelector('.icon.toggle-icon'); 3400 if (icon) icon.classList.remove('rotated'); 3401 } 3402 }); 3403 3404 // Close all flyouts 3405 document.querySelectorAll('.horizontal-nested-dropdown').forEach((flyout) => { 3406 flyout.style.display = 'none'; 3407 flyout.style.opacity = '0'; 3408 flyout.style.visibility = 'hidden'; 3409 }); 3410 3411 // Remove expanded class from all submenu items 3412 document.querySelectorAll('.horizontal-submenu-item.expanded').forEach((subItem) => { 3413 subItem.classList.remove('expanded'); 3414 }); 3415 }); 3416 } else { 3417 // Keyboard: Enter/Space toggles hover-style dropdown without changing mouse behavior 3418 menuItem.setAttribute('tabindex', '0'); 3419 menuItem.addEventListener('keydown', function(event) { 3420 if (event.key !== 'Enter' && event.key !== ' ') return; 3421 event.preventDefault(); 3422 const isOpen = this.classList.contains('expanded'); 3423 document.querySelectorAll('.horizontal-menu-item.expanded').forEach((openItem) => { 3424 if (openItem !== this) { 3425 openItem.classList.remove('expanded'); 3426 openItem.setAttribute('aria-expanded', 'false'); 3427 const otherIcon = openItem.querySelector('.icon.toggle-icon'); 3428 if (otherIcon) otherIcon.classList.remove('rotated'); 3429 } 3430 }); 3431 this.classList.toggle('expanded', !isOpen); 3432 this.setAttribute('aria-expanded', !isOpen ? 'true' : 'false'); 3433 const currentIcon = this.querySelector('.icon.toggle-icon'); 3434 if (currentIcon) currentIcon.classList.toggle('rotated', !isOpen); 3435 }); 3436 } 3437 return menuItem; 3438} 3439 3440/** 3441* Creates a single horizontal submenu item. 3442* (UPDATED to handle nested children and async loadGallery) 3443* @param {object} galleryItem - The gallery item data for the submenu. 3444* @returns {HTMLElement | null} The created submenu item element, or null if not rendered. 3445*/ 3446function createHorizontalSubMenuItem(galleryItem) { 3447 if (galleryItem.visible === false || galleryItem.isSpacer) { 3448 return null; 3449 } 3450 3451 const subMenuItem = document.createElement('li'); 3452 subMenuItem.className = 'horizontal-submenu-item'; 3453 subMenuItem.dataset.id = String(galleryItem.id); 3454 subMenuItem.setAttribute('data-visible', galleryItem.visible !== false ? 'true' : 'false'); 3455 3456 const subVisibleChildren = (galleryItem.children || []).filter(child => child.visible !== false && !child.isSpacer); 3457 const isNavigableParent = isNavigableMenuParent(galleryItem); 3458 let titleSpan; 3459 if (subVisibleChildren.length > 0 && !isNavigableParent) { 3460 titleSpan = document.createElement('span'); 3461 titleSpan.className = 'horizontal-menu-label'; 3462 titleSpan.textContent = galleryItem.title; 3463 } else { 3464 titleSpan = document.createElement('a'); 3465 titleSpan.className = 'horizontal-menu-label'; 3466 titleSpan.href = typeof getMenuItemHref === 'function' ? getMenuItemHref(galleryItem) : '#'; 3467 titleSpan.textContent = galleryItem.title; 3468 if (galleryItem.isExternal) { 3469 titleSpan.target = '_blank'; 3470 titleSpan.rel = 'noopener noreferrer'; 3471 } else { 3472 titleSpan.addEventListener('click', (e) => { e.preventDefault(); }); 3473 } 3474 } 3475 subMenuItem.appendChild(titleSpan); 3476 3477 const currentGlobalActiveId = parseInt(String(window.activeGalleryId || activeGalleryId)); 3478 if (galleryItem.id === currentGlobalActiveId) { 3479 subMenuItem.classList.add('active'); 3480 if (titleSpan.tagName === 'A') titleSpan.setAttribute('aria-current', 'page'); 3481 } 3482 3483 // Check if this submenu item has children (nested sub-sub items) 3484 if (galleryItem.children && galleryItem.children.length > 0) { 3485 const visibleChildren = subVisibleChildren; 3486 3487 if (visibleChildren.length > 0) { 3488 subMenuItem.classList.add('has-children'); 3489 3490 // Add arrow indicator for nested items (same as top-level menu) 3491 const arrowSpan = document.createElement(isNavigableParent ? 'button' : 'span'); 3492 arrowSpan.className = 'submenu-arrow'; 3493 if (isNavigableParent) { 3494 arrowSpan.type = 'button'; 3495 arrowSpan.setAttribute('aria-label', 'Show submenu for ' + galleryItem.title); 3496 arrowSpan.setAttribute('aria-expanded', 'false'); 3497 arrowSpan.addEventListener('click', function(event) { 3498 event.preventDefault(); 3499 event.stopPropagation(); 3500 const isOpen = subMenuItem.classList.contains('expanded'); 3501 if (isOpen) { 3502 closeHorizontalNestedFlyout(subMenuItem); 3503 } else { 3504 openHorizontalNestedFlyout(subMenuItem); 3505 } 3506 }); 3507 } 3508 const svgIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); 3509 svgIcon.setAttribute("class", "icon toggle-icon"); 3510 svgIcon.setAttribute("viewBox", "0 0 24 24"); 3511 svgIcon.setAttribute("fill", "none"); 3512 svgIcon.setAttribute("stroke", "currentColor"); 3513 svgIcon.setAttribute("stroke-width", "2"); 3514 const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); 3515 polyline.setAttribute("points", "9 6 15 12 9 18"); // Right-pointing arrow
3516 svgIcon.appendChild(polyline); 3517 arrowSpan.appendChild(svgIcon); 3518 subMenuItem.appendChild(arrowSpan); 3519 3520 // Create nested submenu as a true flyout 3521 const nestedSubMenu = document.createElement('div'); 3522 nestedSubMenu.className = 'horizontal-nested-dropdown'; 3523 3524 // Create a list container for the nested items 3525 const nestedList = document.createElement('ul'); 3526 nestedList.className = 'horizontal-nested-list'; 3527 3528 visibleChildren.forEach((childItem) => { 3529 const nestedSubMenuItem = createHorizontalSubMenuItem(childItem); // Recursive call 3530 if (nestedSubMenuItem) { 3531 nestedList.appendChild(nestedSubMenuItem); 3532 } 3533 }); 3534 3535 nestedSubMenu.appendChild(nestedList); 3536 3537 if (nestedSubMenu.hasChildNodes()) { 3538 // Append to document body for true flyout behavior 3539 document.body.appendChild(nestedSubMenu); 3540 // Store reference to the flyout for cleanup 3541 const flyoutId = 'flyout-' + Date.now() + '-' + Math.random(); 3542 subMenuItem.setAttribute('data-flyout-id', flyoutId); 3543 nestedSubMenu.setAttribute('data-flyout-id', flyoutId); 3544 3545 console.log('Created flyout with ID:', flyoutId, 'for item:', galleryItem.title); // Debug 3546 console.log('Flyout element:', nestedSubMenu); // Debug 3547 3548 // Add hover events for nested submenu 3549 let nestedLeaveTimer; 3550 3551 // Shared open/close used by hover AND keyboard â mouse path unchanged 3552 function openHorizontalNestedFlyout(hostItem) { 3553 if (!hostItem) return; 3554 if (hostItem.nestedLeaveTimer) { 3555 clearTimeout(hostItem.nestedLeaveTimer); 3556 } 3557 const parentMenuItem = hostItem.closest('.horizontal-menu-item'); 3558 if (parentMenuItem && parentMenuItem.leaveTimer) { 3559 clearTimeout(parentMenuItem.leaveTimer); 3560 } 3561 document.querySelectorAll('.horizontal-submenu-item.expanded').forEach((openItem) => { 3562 if (openItem !== hostItem) { 3563 openItem.classList.remove('expanded'); 3564 openItem.setAttribute('aria-expanded', 'false'); 3565 const otherFlyoutId = openItem.getAttribute('data-flyout-id'); 3566 if (otherFlyoutId) { 3567 const otherFlyout = document.querySelector('.horizontal-nested-dropdown[data-flyout-id="' + otherFlyoutId + '"]'); 3568 if (otherFlyout) { 3569 otherFlyout.style.display = 'none'; 3570 otherFlyout.style.opacity = '0'; 3571 otherFlyout.style.visibility = 'hidden'; 3572 } 3573 } 3574 } 3575 }); 3576 hostItem.classList.add('expanded'); 3577 hostItem.setAttribute('aria-expanded', 'true'); 3578 const hostArrow = hostItem.querySelector('.submenu-arrow'); 3579 if (hostArrow && isNavigableParent) hostArrow.setAttribute('aria-expanded', 'true'); 3580 const hostFlyoutId = hostItem.getAttribute('data-flyout-id'); 3581 if (!hostFlyoutId) return; 3582 const nestedDropdown = document.querySelector('.horizontal-nested-dropdown[data-flyout-id="' + hostFlyoutId + '"]'); 3583 if (!nestedDropdown) return; 3584 const rect = hostItem.getBoundingClientRect(); 3585 const parentDropdown = hostItem.closest('.horizontal-dropdown'); 3586 const parentRect = parentDropdown ? parentDropdown.getBoundingClientRect() : rect; 3587 const leftPos = parentRect.right - 1; 3588 const topPos = rect.top; 3589 nestedDropdown.style.left = leftPos + 'px'; 3590 nestedDropdown.style.top = topPos + 'px'; 3591 nestedDropdown.style.display = 'block'; 3592 nestedDropdown.style.opacity = '1'; 3593 nestedDropdown.style.visibility = 'visible'; 3594 nestedDropdown.style.position = 'fixed'; 3595 nestedDropdown.style.zIndex = '1251'; 3596 setTimeout(() => { 3597 const dropdownRect = nestedDropdown.getBoundingClientRect(); 3598 if (dropdownRect.right > window.innerWidth) { 3599 nestedDropdown.style.left = (parentRect.left - dropdownRect.width - 5) + 'px'; 3600 } 3601 }, 10); 3602 } 3603 3604 function closeHorizontalNestedFlyout(hostItem) { 3605 if (!hostItem) return; 3606 hostItem.classList.remove('expanded'); 3607 hostItem.setAttribute('aria-expanded', 'false'); 3608 const hostArrow = hostItem.querySelector('.submenu-arrow'); 3609 if (hostArrow && isNavigableParent) hostArrow.setAttribute('aria-expanded', 'false'); 3610 const hostFlyoutId = hostItem.getAttribute('data-flyout-id'); 3611 if (!hostFlyoutId) return; 3612 const nestedDropdown = document.querySelector('.horizontal-nested-dropdown[data-flyout-id="' + hostFlyoutId + '"]'); 3613 if (nestedDropdown) { 3614 nestedDropdown.style.display = 'none'; 3615 nestedDropdown.style.opacity = '0'; 3616 nestedDropdown.style.visibility = 'hidden'; 3617 } 3618 } 3619 3620 // Keyboard opens the same flyout hover shows â attrs on label so mouse click focus stays natural 3621 if (titleSpan && !isNavigableParent) { 3622 titleSpan.setAttribute('tabindex', '0'); 3623 titleSpan.setAttribute('role', 'button'); 3624 titleSpan.setAttribute('aria-haspopup', 'true'); 3625 titleSpan.setAttribute('aria-expanded', 'false'); 3626 titleSpan.addEventListener('keydown', function(event) { 3627 if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowRight') { 3628 event.preventDefault(); 3629 event.stopPropagation(); 3630 openHorizontalNestedFlyout(subMenuItem); 3631 titleSpan.setAttribute('aria-expanded', 'true'); 3632 const hostFlyoutId = subMenuItem.getAttribute('data-flyout-id'); 3633 const nestedDropdown = hostFlyoutId 3634 ? document.querySelector('.horizontal-nested-dropdown[data-flyout-id="' + hostFlyoutId + '"]') 3635 : null; 3636 const firstLink = nestedDropdown && nestedDropdown.querySelector('a.horizontal-menu
3636-label'); 3637 if (firstLink && typeof firstLink.focus === 'function') { 3638 try { firstLink.focus(); } catch (e) {} 3639 } 3640 } else if (event.key === 'Escape' || event.key === 'ArrowLeft') { 3641 event.preventDefault(); 3642 event.stopPropagation(); 3643 closeHorizontalNestedFlyout(subMenuItem); 3644 titleSpan.setAttribute('aria-expanded', 'false'); 3645 } 3646 }); 3647 } 3648 3649 subMenuItem.addEventListener('mouseenter', function() { 3650 openHorizontalNestedFlyout(this); 3651 if (titleSpan) titleSpan.setAttribute('aria-expanded', 'true'); 3652 }); 3653 3654 subMenuItem.addEventListener('mouseleave', function(event) { 3655 // Check if we're moving to the flyout 3656 const flyoutId = this.getAttribute('data-flyout-id'); 3657 const relatedTarget = event.relatedTarget; 3658 const isMovingToFlyout = relatedTarget && flyoutId && 3659 relatedTarget.closest('.horizontal-nested-dropdown[data-flyout-id="' + flyoutId + '"]'); 3660 3661 // Also check if we're moving within the dropdown 3662 const parentMenuItem = this.closest('.horizontal-menu-item'); 3663 const isMovingWithinDropdown = relatedTarget && parentMenuItem && parentMenuItem.contains(relatedTarget); 3664 3665 console.log('Submenu item mouseleave - moving to flyout:', isMovingToFlyout); 3666 console.log('Submenu item mouseleave - moving within dropdown:', isMovingWithinDropdown); 3667 3668 if (!isMovingToFlyout && !isMovingWithinDropdown) { 3669 this.nestedLeaveTimer = setTimeout(() => { 3670 closeHorizontalNestedFlyout(this); 3671 if (titleSpan) titleSpan.setAttribute('aria-expanded', 'false'); 3672 3673 // Now that the flyout is closed, allow the parent dropdown to close 3674 const parentMenuItem = this.closest('.horizontal-menu-item'); 3675 if (parentMenuItem) { 3676 setTimeout(() => { 3677 const activeFlyout = document.querySelector('.horizontal-nested-dropdown[style*="display: block"]'); 3678 if (!activeFlyout) { 3679 parentMenuItem.classList.remove('expanded'); 3680 const currentIcon = parentMenuItem.querySelector('.icon.toggle-icon'); 3681 if (currentIcon) currentIcon.classList.remove('rotated'); 3682 } 3683 }, 50); 3684 } 3685 }, 200); 3686 } else { 3687 console.log('Submenu item mouseleave - not starting timer (moving to flyout)'); 3688 } 3689 }); 3690 3691 nestedSubMenu.addEventListener('mouseenter', function() { 3692 console.log('Flyout mouseenter - clearing timers'); 3693 clearTimeout(nestedLeaveTimer); 3694 3695 // Clear the parent dropdown's leave timer to keep it open 3696 const flyoutId = this.getAttribute('data-flyout-id'); 3697 if (flyoutId) { 3698 const parentSubItem = document.querySelector('.horizontal-submenu-item[data-flyout-id="' + flyoutId + '"]'); 3699 if (parentSubItem) { 3700 const parentMenuItem = parentSubItem.closest('.horizontal-menu-item'); 3701 if (parentMenuItem && parentMenuItem.leaveTimer) { 3702 console.log('Clearing parent menu item leave timer'); 3703 clearTimeout(parentMenuItem.leaveTimer); 3704 parentMenuItem.leaveTimer = null; 3705 } else { 3706 console.log('No parent menu item leave timer to clear'); 3707 } 3708 if (parentSubItem.nestedLeaveTimer) { 3709 console.log('Clearing submenu item nested leave timer'); 3710 clearTimeout(parentSubItem.nestedLeaveTimer); 3711 parentSubItem.nestedLeaveTimer = null; 3712 } 3713 } 3714 } 3715 }); 3716 3717 nestedSubMenu.addEventListener('mouseleave', function() { 3718 const flyoutId = this.getAttribute('data-flyout-id'); 3719 if (flyoutId) { 3720 const parentSubItem = document.querySelector('.horizontal-submenu-item[data-flyout-id="' + flyoutId + '"]'); 3721 if (parentSubItem) { 3722 nestedLeaveTimer = setTimeout(() => { 3723 closeHorizontalNestedFlyout(parentSubItem); 3724 const label = parentSubItem.querySelector('.horizontal-menu
3724-label'); 3725 if (label) label.setAttribute('aria-expanded', 'false'); 3726 }, 200); 3727 } 3728 } 3729 }); 3730 } 3731 } 3732 } 3733 3734 // Click listener for navigation (only if no children or if it's a direct link) 3735 if (!subMenuItem.classList.contains('has-children') || isPageMenuItem(galleryItem) || isNavigableParent) { 3736 subMenuItem.addEventListener('click', async (event) => { 3737 if (isNavigableParent && subVisibleChildren.length > 0 && !titleSpan.contains(event.target)) { 3738 return; 3739 } 3740 event.stopPropagation(); 3741 const clickedItemId = galleryItem.id; 3742 3743 // Check if it's an external URL first 3744 if (galleryItem.isExternal && galleryItem.url) { 3745 window.open(galleryItem.url, '_blank', 'noopener,noreferrer'); 3746 return; 3747 } 3748 3749 if (isPageMenuItem(galleryItem)) { 3750 if (typeof loadPage === 'function') { 3751 loadPage(clickedItemId, event); 3752 if (typeof updateActiveStatesHorizontal === 'function') { 3753 console.log("SubMenu Click Handler (Page): Calling updateActiveStatesHorizontal for ID:", clickedItemId); 3754 setTimeout(() => updateActiveStatesHorizontal(clickedItemId), 50); 3755 } 3756 } 3757 } else { // It's a gallery 3758 if (typeof loadGallery === 'function') { 3759 console.log("SubMenu Click Handler (Gallery): Awaiting loadGallery for ID:", clickedItemId); 3760 await loadGallery(clickedItemId, event); 3761 console.log("SubMenu Click Handler (Gallery): loadGallery completed for ID:", clickedItemId, ". Calling updateActiveStatesHorizontal with delay."); 3762 if (typeof updateActiveStatesHorizontal === 'function') { 3763 setTimeout(() => { 3764 console.log("[Delayed Update from SubMenu Click] Calling updateActiveStatesHorizontal for gallery ID:", clickedItemId); 3765 updateActiveStatesHorizontal(clickedItemId); 3766 }, 100); 3767 } 3768 } 3769 } 3770 3771 // Close all parent dropdowns and flyouts after navigation 3772 document.querySelectorAll('.horizontal-menu-item.expanded, .horizontal-submenu-item.expanded').forEach((openItem) => { 3773 openItem.classList.remove('expanded'); 3774 const icon = openItem.querySelector('.icon.toggle-icon'); 3775 if (icon) icon.classList.remove('rotated'); 3776 }); 3777 3778 // Close all flyouts 3779 document.querySelectorAll('.horizontal-nested-dropdown').forEach((flyout) => { 3780 flyout.style.display = 'none'; 3781 flyout.style.opacity = '0'; 3782 flyout.style.visibility = 'hidden'; 3783 }); 3784 }); 3785 } 3786 3787 return subMenuItem; 3788} 3789 3790/** 3791* Updates the 'active' class for items in the horizontal menu. 3792*/ 3793 3794function updateActiveStatesHorizontal(forcedActiveId = null) { 3795 // Prioritize forcedActiveId if provided, otherwise use global state. 3796 // Ensure the ID is treated as a number for comparison. 3797 const targetId = forcedActiveId !== null 3798 ? parseInt(String(forcedActiveId)) 3799 : parseInt(String(window.activeGalleryId || activeGalleryId)); 3800 3801 console.log("[Horizontal Menu Update] Initiated. Target Active ID:", targetId, 3802 (forcedActiveId !== null ? `(Forced: ${forcedActiveId})` : "(Global)"), 3803 "Type:", typeof targetId); 3804 3805 const menuItems = document.querySelectorAll('.horizontal-menu-item, .horizontal-submenu-item'); 3806 3807 if (menuItems.length === 0 && !isNaN(targetId)) { // Check if targetId is a valid number 3808 console.warn("[Horizontal Menu Update] No horizontal menu items found in DOM to update. Target ID was:", targetId); 3809 return; 3810 } 3811 3812 let activeItemSet = false; 3813 menuItems.forEach(item => { 3814 const itemIdStr = item.dataset.id; 3815 if (!itemIdStr) { 3816 // console.warn("[Horizontal Menu Update] Menu item found without a data-id attribute:", item); 3817 return; 3818 } 3819 const itemId = parseInt(itemIdStr); 3820 3821 if (itemId === targetId) { 3822 if (!item.classList.contains('active')) { 3823 item.classList.add('active'); 3824 console.log(`[Horizontal Menu Update] ADDED .active to item ID ${itemId} ('${item.textContent.trim()}') using target ID ${targetId}`); 3825 } 3826 activeItemSet = true; 3827 } else { 3828 if (item.classList.contains('active')) { 3829 item.classList.remove('active'); 3830 console.log(`[Horizontal Menu Update] REMOVED .active from item ID ${itemId} ('${item.textContent.trim()}') using target ID ${targetId}`); 3831 } 3832 } 3833 }); 3834 3835 if (!isNaN(targetId) && !activeItemSet) { // Check if targetId is a valid number 3836 console.warn(`[Horizontal Menu Update] Target Active ID was ${targetId}, but NO matching horizontal menu item was made active. Check data-id attributes and item visibility.`); 3837 } 3838 3839 const activeSubItem = document.querySelector('.horizontal-submenu-item.active'); 3840 if (activeSubItem) { 3841 const parentTopItem = activeSubItem.closest('.horizontal-menu
3841-item.has-children'); 3842 if (parentTopItem && !parentTopItem.classList.contains('active')) { 3843 // parentTopItem.classList.add('active-parent'); // Optional class for styling parent 3844 } 3845 } 3846 console.log("[Horizontal Menu Update] Completed for Target ID:", targetId); 3847} 3848 3849 3850 // Listener for closing dropdowns when clicking outside 3851 document.addEventListener('click', function(event) { 3852 if (currentMenuLayout === 'horizontal') { 3853 const openSubmenus = document.querySelectorAll('.horizontal-menu-item.expanded'); 3854 let clickedInsideSubmenuOrParent = false; 3855 3856 openSubmenus.forEach(submenuContainer => { 3857 // Check if click is on the submenu container OR its direct parent menu item 3858 if (submenuContainer.contains(event.target) || 3859 (submenuContainer.parentElement && submenuContainer.parentElement.contains(event.target) && submenuContainer.parentElement.classList.contains('horizontal-menu-item'))) { 3860 clickedInsideSubmenuOrParent = true; 3861 } 3862 }); 3863 3864 // If click is on another top-level item that is NOT expanded, also don't close 3865 const targetMenuItem = event.target.closest('.horizontal-menu-item'); 3866 if (targetMenuItem && !targetMenuItem.classList.contains('expanded') && targetMenuItem.classList.contains('has-children')) { 3867 clickedInsideSubmenuOrParent = true; // Don't close if clicking another parent to open it 3868 } 3869 3870 3871 if (!clickedInsideSubmenuOrParent) { 3872 openSubmenus.forEach(submenuContainer => { 3873 submenuContainer.classList.remove('expanded'); 3874 }); 3875 } 3876 } 3877 }); 3878 3879document.addEventListener('menuLayoutChanged', function(e) { 3880 const newLayout = e.detail.layout; 3881 console.log('EVENT: menuLayoutChanged detected. New layout:', newLayout, 'Current isEditing state:', isEditing); 3882 3883 // Update the global currentMenuLayout variable if you have one 3884 if (typeof currentMenuLayout !== 'undefined') { 3885 currentMenuLayout = newLayout; 3886 } 3887 3888 const sidebar = document.querySelector('.sidebar'); // Ensure sidebar DOM element is selected 3889 3890 // Always clear and re-render the appropriate menu structure for the new layout. 3891 // This is important for style changes (like font size) that affect dimensions. 3892 const galleryTreeContainer = document.getElementById('galleryTree'); 3893 const mobileHeaderContent = document.querySelector('.mobile-header-content'); 3894 3895 if (galleryTreeContainer) { 3896 galleryTreeContainer.innerHTML = ''; // Clear existing tree 3897 } 3898 if (mobileHeaderContent) { 3899 const horizontalMenuContainer = mobileHeaderContent.querySelector('.horizontal-menu-container'); 3900 if (horizontalMenuContainer) { 3901 horizontalMenuContainer.innerHTML = ''; // Clear only items, not the container itself 3902 } 3903 } 3904 3905 // Render the correct menu structure based on the new layout 3906 if (newLayout === 'horizontal') { 3907 if (typeof renderHorizontalMenu === 'function') { 3908 renderHorizontalMenu(); 3909 } 3910 // CSS rules will handle sidebar visibility based on .edit-mode-active and .menu-layout-horizontal 3911 } else { // 'sidebar' or 'top' 3912 if (typeof renderGalleries === 'function') { 3913 renderGalleries(); 3914 } 3915 // CSS rules will handle sidebar visibility 3916 } 3917 3918 // If currently in edit mode, ensure the UI components for editing are correctly displayed 3919 // for the new layout, without fully toggling the mode off and on. 3920 if (isEditing && typeof window.updateGlobalEditState === 'function') { 3921 console.log('menuLayoutChanged: In edit mode. Ensuring edit UI is consistent for new layout.'); 3922 3923 // 1. Forcefully ensure global state and body/sidebar classes are correct for edit mode. 3924 // updateGlobalEditState should handle adding .edit-mode-active to body and .editing to sidebar. 3925 window.updateGlobalEditState(true); 3926 3927 // 2. Re-initialize sortables. It's often safer to destroy existing ones first. 3928 if (typeof initializeNestedSortables === 'function') { 3929 if (typeof destroyNestedSortables === 'function') { 3930 destroyNestedSortables(); 3931 } 3932 setTimeout(initializeNestedSortables, 100); // Delay for DOM updates 3933 } 3934 // Also reinitialize for SidebarManager if it exists and handles its own sortables 3935 if (window.SidebarManager && typeof window.SidebarManager._reinitializeNestedSortables === 'function') { 3936 setTimeout(() => window.SidebarManager._reinitializeNestedSortables(), 150); 3937 } 3938 3939 // 3. Ensure edit controls (the top bar of buttons in the sidebar) are visible
3940 const editControls = document.querySelector('.edit-controls'); 3941 if (editControls) { 3942 editControls.style.display = 'flex'; // Or your default display type for these controls 3943 editControls.classList.add('visible'); 3944 } 3945 3946 // 4. If the layout is now horizontal, the sidebar (for editing) should be visible. 3947 // The CSS rule `body.menu-layout-horizontal.edit-mode-active .sidebar { display: block !important; }` 3948 // should handle this. This log is for confirmation. 3949 if (newLayout === 'horizontal' && sidebar) { 3950 console.log('menuLayoutChanged: Horizontal layout in edit mode, sidebar should be visible via CSS.'); 3951 } 3952 3953 // 5. If PageManager is active and a page is loaded, ensure its edit mode is also set. 3954 if (window.PageManager && typeof window.PageManager.setEditMode === 'function' && 3955 window.PageManager.getCurrentPageId && window.PageManager.getCurrentPageId()) { 3956 window.PageManager.setEditMode(true); 3957 } 3958 3959 } else if (!isEditing) { 3960 // If not in edit mode, CSS should handle the view state. 3961 // This block can be used for any explicit view mode adjustments if CSS isn't sufficient. 3962 console.log('menuLayoutChanged: Not in edit mode. CSS will handle view state.'); 3963 } 3964 3965 // Update mobile title and close mobile menu if open, regardless of edit mode 3966 if (typeof updateMobileTitle === 'function') { 3967 updateMobileTitle(); 3968 } 3969 if (typeof closeMobileMenu === 'function') { 3970 closeMobileMenu(); 3971 } 3972 3973 // Re-render the active page so row-band insets and recipe layout match the new menu shell 3974 if (window.PageManager && typeof window.PageManager.renderElements === 'function' && window.PageManager.currentPageId) { 3975 console.log('menuLayoutChanged: Re-rendering page', window.PageManager.currentPageId); 3976 window.PageManager.renderElements(window.PageManager.currentPageId); 3977 } 3978 if (typeof ensureCorrectLayoutApplied === 'function') { 3979 setTimeout(ensureCorrectLayoutApplied, 0); 3980 } 3981}); 3982 3983 3984// Make sure getFirstSidebarElementForHeader is available if called from other scripts 3985if (typeof window.getFirstSidebarElementForHeader === 'undefined') { 3986 window.getFirstSidebarElementForHeader = getFirstSidebarElementForHeader; 3987} 3988 3989// Make sure getFirstSocialElementForHeader is available if called from other scripts 3990if (typeof window.getFirstSocialElementForHeader === 'undefined') { 3991 window.getFirstSocialElementForHeader = getFirstSocialElementForHeader; 3992} 3993 3994// Make sure renderSocialIcons is available if called from other scripts 3995if (typeof window.renderSocialIcons === 'undefined') { 3996 window.renderSocialIcons = renderSocialIcons; 3997} 3998if (typeof window.renderHorizontalMenu === 'undefined') { 3999 window.renderHorizontalMenu = renderHorizontalMenu; 4000} 4001if (typeof window.createHorizontalMenuItem === 'undefined') { 4002 window.createHorizontalMenuItem = createHorizontalMenuItem; 4003} 4004if (typeof window.createHorizontalSubMenuItem === 'undefined') { 4005 window.createHorizontalSubMenuItem = createHorizontalSubMenuItem; 4006} 4007if (typeof window.updateActiveStatesHorizontal === 'undefined') { 4008 window.updateActiveStatesHorizontal = updateActiveStatesHorizontal; 4009} 4010// Ensure toggleEditMode is globally available if it's the primary one. 4011if (typeof window.toggleEditMode === 'undefined' || window.toggleEditMode.toString().length < 100) { // Heuristic to check if it's a placeholder 4012 window.toggleEditMode = toggleEditMode; 4013} 4014 4015 4016// Helper function to toggle edit controls visibility 4017function toggleEditControlsVisibility(show) { 4018 const editControls = document.querySelector('.edit-controls'); 4019 if (editControls) { 4020 if (show) { 4021 editControls.classList.add('visible'); 4022 editControls.style.display = 'flex'; 4023 } else { 4024 editControls.classList.remove('visible'); 4025 editControls.style.display = 'none'; 4026 } 4027 } 4028} 4029 4030/** Reliable + shortcut across US/Intl layouts, numpad, and browsers (key/keyCode differs). */ 4031function isHydraPlusKey(e) { 4032 if (e.repeat) return false;
4033 if (e.ctrlKey || e.metaKey || e.altKey) return false; 4034 if (e.key === '+' || e.key === 'Add') return true; 4035 if (e.code === 'NumpadAdd') return true; 4036 if ((e.code === 'Equal' || e.key === '=') && e.shiftKey) return true; 4037 if (e.keyCode === 187 && e.shiftKey) return true; 4038 if (e.keyCode === 107) return true; 4039 return false; 4040} 4041 4042function handleHydraPlusShortcut() { 4043 try { 4044 const token = window.didToken || localStorage.getItem('hydra_auth_token'); 4045 const isAdmin = window.isAdmin || localStorage.getItem('hydra_is_admin') === 'true'; 4046 if (token && isAdmin) { 4047 console.log('User already logged in - toggling edit mode'); 4048 if (typeof window.toggleEditMode === 'function') { 4049 window.toggleEditMode(); 4050 } else if (typeof toggleEditMode === 'function') { 4051 toggleEditMode(); 4052 } else { 4053 console.warn('toggleEditMode function not found'); 4054 } 4055 } else { 4056 const p = startOTPLogin(); 4057 if (p && typeof p.then === 'function') { 4058 p.catch(function(err) { 4059 console.error('startOTPLogin failed:', err); 4060 }); 4061 } 4062 } 4063 } catch (err) { 4064 console.error('Hydra + key handler error:', err); 4065 } 4066} 4067 4068document.addEventListener('keydown', function(e) { 4069 if (!isHydraPlusKey(e)) return; 4070 e.preventDefault(); 4071 handleHydraPlusShortcut(); 4072}, true); 4073 4074// Custom HTML pages load in a sandboxed iframe; + is captured there unless forwarded via postMessage. 4075function isHydraCustomPageHtmlPlusMessage(e) { 4076 if (!e.data || e.data.source !== 'hydra-custom-page-html' || e.data.type !== 'plus-key') return false; 4077 if (e.origin === window.location.origin) return true; 4078 // Sandboxed iframes (no allow-same-origin) post with origin "null" â accept only from our iframe. 4079 if (e.origin !== 'null' || !e.source) return false; 4080 const iframes = document.querySelectorAll('iframe.page-custom-html-iframe'); 4081 for (let i = 0; i < iframes.length; i++) { 4082 if (iframes[i].contentWindow === e.source) return true; 4083 } 4084 return false; 4085} 4086 4087window.addEventListener('message', function(e) { 4088 if (!isHydraCustomPageHtmlPlusMessage(e)) return; 4089 handleHydraPlusShortcut(); 4090}); 4091 4092/** Option+/ (÷) opens Help â Pathways. Option+? opens Help â Ask (help admin only). */ 4093function isHydraHelpKey(e) { 4094 if (e.repeat) return false; 4095 if (e.ctrlKey || e.metaKey) return false; 4096 if (!e.altKey) return false; 4097 if (e.shiftKey) return false; 4098 if (e.key === '÷') return true; 4099 if (e.code === 'Slash' && e.altKey) return true; 4100 return false; 4101} 4102 4103function isHydraHelpAskKey(e) { 4104 if (e.repeat) return false; 4105 if (e.ctrlKey || e.metaKey) return false; 4106 if (!e.altKey || !e.shiftKey) return false; 4107 if (e.key === '?' || e.key === '¿') return true; 4108 if (e.code === 'Slash') return true; 4109 return false; 4110} 4111 4112document.addEventListener('keydown', function(e) { 4113 if (!window.HYDRA_HELP_ENABLED) return; 4114 var t = e.target; 4115 if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; 4116 if (isHydraHelpAskKey(e)) { 4117 e.preventDefault(); 4118 e.stopPropagation(); 4119 openHydraHelp({ initialTab: 'ask' }); 4120 return; 4121 } 4122 if (!isHydraHelpKey(e)) return; 4123 e.preventDefault(); 4124 e.stopPropagation(); 4125 openHydraHelp(); 4126}, true); 4127 4128// Email OTP Authentication with Enhanced Debugging 4129 4130 4131// Add this function to the client-side code 4132// Improved client-side backup function with better token handling 4133async function createLoginBackup() { 4134 try { 4135 console.log('Attempting to create login backup...'); 4136 4137 // Get authentication info from multiple possible sources 4138 let token = window.didToken || localStorage.getItem('hydra_auth_token'); 4139 const email = localStorage.getItem('hydra_auth_email') || 4140 (window.userMetadata ? window.userMetadata.email : '') || 4141 ''; 4142 4143 // Check if we have authentication 4144 if (!token) { 4145 console.error('No auth token available for backup'); 4146 return; 4147 } 4148 4149 if (!email) { 4150 console.error('No email available for backup - make sure it was stored during login'); 4151 return; 4152 } 4153 4154 console.log(`Creating backup with token (${token.length} chars) for ${email}`); 4155 4156 // Format the token correctly - VERY IMPORTANT 4157 // If it's already a hydra token (starts with hydra:), use it as is 4158 // Otherwise, add the hydra: prefix 4159 const formattedToken = token.startsWith('hydra:') ? token : `hydra:${token}`; 4160 4161 console.log(`Using formatted token: ${formattedToken.substring(0, 15)}...`); 4162 4163 // Get the API URL with preview handling if needed 4164 const backupUrl = typeof getApiUrl === 'function' ? 4165 getApiUrl('/api/create-backup') : 4166 '/api/create-backup'; 4167 4168 console.log(`Sending backup request to: ${backupUrl}`); 4169 4170 // IMPORTANT: Make sure to add the Bearer prefix to the Authorization header 4171 const response = await fetch(backupUrl, { 4172 method: 'POST', 4173 headers: { 4174 'Authorization': `Bearer ${formattedToken}`, 4175 'Content-Type': 'application/json', 4176 'X-User-Email': email, // Include email as fallback 4177 'X-API-Request': 'true' // Mark as API request 4178 } 4179 }); 4180 4181 // Handle response 4182 if (!response.ok) { 4183 const errorText = await response.text(); 4184 let errorData;
4185 try { 4186 // Try to parse as JSON 4187 errorData = JSON.parse(errorText); 4188 console.error('Backup failed:', errorData.error, errorData); 4189 } catch { 4190 // Not JSON, log raw text 4191 console.error('Backup failed with status', response.status, errorText); 4192 } 4193 return; 4194 } 4195 4196 // Parse successful response 4197 const result = await response.json(); 4198 console.log(`â Backup created successfully: ${result.backup}`); 4199 } catch (error) { 4200 console.error('Error in backup process:', error); 4201 } 4202} 4203 4204// Initialize Magic with detailed logging 4205function initMagic() { 4206 try { 4207 // If Magic is already initialized, return it 4208 if (window.magic) { 4209 return window.magic; 4210 } 4211 4212 console.log('Initializing Magic SDK...'); 4213 4214 // Initialize with minimal options 4215 const magic = new Magic('pk_live_E0B68BA6DCA8C75F', { 4216 testMode: false 4217 }); 4218 4219 // Store reference globally 4220 window.magic = magic; 4221 4222 console.log('Magic SDK initialized successfully'); 4223 return magic; 4224 } catch (error) { 4225 console.error('Error initializing Magic:', error); 4226 throw error; 4227 } 4228} 4229 4230// Start OTP login flow with proper event handling 4231async function startOTPLogin() { 4232 try { 4233 // Initialize Magic 4234 const magic = initMagic(); 4235 4236 // Get email from user (styled overlay â same auth path as native prompt) 4237 const email = await showEmailLoginOverlay(); 4238 if (!email) return; // User cancelled 4239 4240 console.log('Starting Email OTP login for:', email); 4241 showLoadingOverlay('Sending verification code...'); 4242 4243 // Check if Magic's event enum types are available - for newer Magic SDK versions 4244 // If not available, fall back to string-based events 4245 const useEnums = typeof LoginWithEmailOTPEventOnReceived !== 'undefined' && 4246 typeof LoginWithEmailOTPEventEmit !== 'undefined'; 4247 4248 console.log('Using enum-based events:', useEnums); 4249 4250 // Create a timeout handler 4251 let emailSentTimeout = setTimeout(() => { 4252 console.log('No email-otp-sent event received, using fallback UI'); 4253 hideLoadingOverlay(); 4254 showOTPInputOverlay(email); 4255 }, 10000); 4256 4257 // Create OTP login handle with showUI: false 4258 const handle = magic.auth.loginWithEmailOTP({ 4259 email, 4260 showUI: false, 4261 deviceCheckUI: false 4262 }); 4263 4264 // Track authentication state 4265 window.pendingAuth = { 4266 email, 4267 handle 4268 }; 4269 4270 // Listen for events - using both enum-based and string-based event handling 4271 console.log('Setting up OTP event handlers...'); 4272 4273 // Email OTP sent event 4274 if (useEnums) { 4275 handle.on(LoginWithEmailOTPEventOnReceived.EmailOTPSent, () => { 4276 console.log('EVENT: EmailOTPSent (enum) - Email was sent successfully'); 4277 clearTimeout(emailSentTimeout); 4278 hideLoadingOverlay(); 4279 showOTPInputOverlay(email); 4280 }); 4281 } 4282 4283 // Also listen for string-based event (for backward compatibility) 4284 handle.on('email-otp-sent', () => { 4285 console.log('EVENT: email-otp-sent - Email was sent successfully'); 4286 clearTimeout(emailSentTimeout); 4287 hideLoadingOverlay(); 4288 showOTPInputOverlay(email); 4289 }); 4290 4291 // Invalid OTP event 4292 if (useEnums) { 4293 handle.on(LoginWithEmailOTPEventOnReceived.InvalidEmailOtp, () => { 4294 console.log('EVENT: InvalidEmailOtp (enum) - Invalid code entered'); 4295 showErrorMessage('Invalid verification code. Please try again.'); 4296 updateOTPInputForRetry(); 4297 }); 4298 } 4299 4300 // Also listen for string-based event 4301 handle.on('invalid-email-otp', () => { 4302 console.log('EVENT: invalid-email-otp - Invalid code entered'); 4303 showErrorMessage('Invalid verification code. Please try again.'); 4304 updateOTPInputForRetry(); 4305 }); 4306 4307 async function handleLoginSuccess(result) { 4308 console.log('OTP verification successful, checking admin status...'); 4309 hideOTPInputOverlay(); 4310 hideLoadingOverlay(); 4311 4312 try { 4313 window.didToken = result; 4314 console.log('DID Token received:', result ? (result.substring(0, 20) + '...') : 'none'); 4315 4316 const userInfo = await magic.user.getInfo(); 4317 window.userMetadata = userInfo; 4318 console.log('User info retrieved:', userInfo); 4319 4320 const isUserAdmin = await checkAdminStatus(result); 4321 4322 if (!isUserAdmin && !window.isAdmin) { 4323 console.log('User is not on admin list, login denied'); 4324 showErrorMessage('Login failed'); 4325 clearHydraClientAuthStorage(); 4326 resetAuthUI();
4327 try { 4328 await magic.user.logout(); 4329 } catch (logoutError) { 4330 console.error('Error logging out non-admin user from Magic:', logoutError); 4331 } 4332 window.pendingAuth = null; 4333 return; 4334 } 4335 4336 console.log('Admin status check completed'); 4337 window.isAuthenticated = true; 4338 isAuthenticated = true; 4339 showSuccessMessage('Login successful!'); 4340 4341 // Store the ID token (JWT) for API calls 4342 try { 4343 const idToken = await magic.user.getIdToken(); 4344 if (idToken && idToken.includes('.')) { 4345 console.log('Got proper JWT token from Magic.link'); 4346 localStorage.setItem('hydra_auth_token', idToken); 4347 } else if (userInfo && userInfo.email) { 4348 const userToken = { 4349 email: userInfo.email, 4350 iat: Math.floor(Date.now() / 1000), 4351 exp: Math.floor(Date.now() / 1000) + (24 * 60 * 60) 4352 }; 4353 const header = btoa(JSON.stringify({ typ: 'JWT', alg: 'none' })); 4354 const payload = btoa(JSON.stringify(userToken)); 4355 const jwtToken = header + '.' + payload + '.nosignature'; 4356 localStorage.setItem('hydra_auth_token', jwtToken); 4357 localStorage.setItem('hydra_auth_email', userInfo.email); 4358 } 4359 localStorage.setItem('hydra_did_token', result); 4360 } catch (tokenError) { 4361 console.error('Error getting ID token:', tokenError); 4362 localStorage.setItem('hydra_auth_token', result); 4363 } 4364 4365 // IMPROVED UI UPDATE APPROACH - Use multiple techniques for redundancy 4366 4367 // 1. Add state classes to body 4368 document.body.classList.add('hydra-authenticated'); 4369 if (window.isAdmin) { 4370 document.body.classList.add('hydra-admin'); 4371 } 4372 4373 // 2. Direct DOM manipulation 4374 const logoutButton = document.getElementById('logoutButton'); 4375 const editButton = document.getElementById('editButton'); 4376 4377 // Create new buttons if they don't exist 4378 if (!logoutButton) { 4379 console.log('Creating logout button'); 4380 const newLogoutButton = document.createElement('button'); 4381 newLogoutButton.id = 'logoutButton'; 4382 newLogoutButton.className = 'btn'; 4383 newLogoutButton.textContent = 'Logout'; 4384 newLogoutButton.onclick = logout; 4385 newLogoutButton.style.display = 'block'; 4386 4387 const sidebarHeader = document.querySelector('.sidebar-header'); 4388 if (sidebarHeader) { 4389 sidebarHeader.appendChild(newLogoutButton); 4390 } else { 4391 document.body.appendChild(newLogoutButton); 4392 } 4393 } else { 4394 logoutButton.style.cssText = 'display: block !important; visibility: visible !important; opacity: 1 !important;'; 4395 } 4396 4397 if (!editButton && window.isAdmin) { 4398 console.log('Creating edit button'); 4399 const newEditButton = document.createElement('button'); 4400 newEditButton.id = 'editButton'; 4401 newEditButton.className = 'btn btn-primary'; 4402 newEditButton.innerHTML = ` 4403 <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 4404 <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path> 4405 <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path> 4406 </svg> 4407 Edit 4408 `; 4409 newEditButton.onclick = toggleEditMode; 4410 newEditButton.style.display = 'block'; 4411 4412 const sidebarHeader = document.querySelector('.sidebar-header'); 4413 if (sidebarHeader) { 4414 sidebarHeader.appendChild(newEditButton); 4415 } else { 4416 document.body.appendChild(newEditButton); 4417 } 4418 } else if (editButton && window.isAdmin) { 4419 editButton.style.cssText = 'display: block !important; visibility: visible !important; opacity: 1 !important;'; 4420 } 4421 4422 // 3. Dispatch event for other listeners 4423 document.dispatchEvent(new CustomEvent('login-complete', { 4424 detail: { 4425 isAdmin: window.isAdmin, 4426 email: window.userMetadata ? window.userMetadata.email : null 4427 } 4428 })); 4429 4430 window.pendingAuth = null; 4431 4432 if (typeof checkAuth === 'function') { 4433 console.log('Running existing checkAuth function'); 4434 setTimeout(checkAuth, 500); 4435 } 4436 } catch (error) { 4437 console.error('Error in login success handling:', error); 4438 showErrorMessage('Login failed'); 4439 clearHydraClientAuthStorage(); 4440 resetAuthUI(); 4441 window.pendingAuth = null; 4442 } 4443} 4444 4445 4446 // Completion event 4447 handle.on('done', async (result) => { 4448 await handleLoginSuccess(result); 4449 }); 4450 4451 // Error event 4452 handle.on('error', (error) => { 4453 console.error('EVENT: error - OTP login error:', error); 4454 hideLoadingOverlay(); 4455 hideOTPInputOverlay(); 4456 showErrorMessage('Login failed: ' + (error.message || 'Unknown error')); 4457 4458 // Clear timeout if still active 4459 clearTimeout(emailSentTimeout); 4460 4461 // Clean up 4462 window.pendingAuth = null; 4463 }); 4464 4465 console.log('OTP event handlers set up successfully'); 4466 4467 } catch (error) { 4468 console.error('Error starting OTP login:', error); 4469 hideLoadingOverlay(); 4470 hideOTPInputOverlay(); 4471 showErrorMessage('Error starting login: ' + error.message); 4472 } 4473} 4474
4475// Submit OTP code with support for enum-based events 4476function submitOTP(code) { 4477 try { 4478 // Validate code input 4479 if (!code || code.trim() === '') { 4480 showErrorMessage('Please enter the verification code.'); 4481 return; 4482 } 4483 4484 // Check if we have a pending authentication 4485 if (!window.pendingAuth || !window.pendingAuth.handle) { 4486 showErrorMessage('No active login session. Please try again.'); 4487 hideOTPInputOverlay(); 4488 return; 4489 } 4490 4491 console.log('Submitting OTP code...'); 4492 showLoadingOverlay('Verifying code...'); 4493 4494 // Check if enum types are available 4495 const useEnums = typeof LoginWithEmailOTPEventEmit !== 'undefined'; 4496 4497 // Verify the OTP code 4498 if (useEnums) { 4499 window.pendingAuth.handle.emit(LoginWithEmailOTPEventEmit.VerifyEmailOtp, code); 4500 } else { 4501 window.pendingAuth.handle.emit('verify-email-otp', code); 4502 } 4503 4504 } catch (error) { 4505 console.error('Error submitting OTP:', error); 4506 hideLoadingOverlay(); 4507 showErrorMessage('Error verifying code: ' + error.message); 4508 } 4509} 4510 4511// Cancel OTP login with support for enum-based events 4512function cancelOTPLogin() { 4513 try { 4514 // Check if we have a pending authentication 4515 if (window.pendingAuth && window.pendingAuth.handle) { 4516 // Check if enum types are available 4517 const useEnums = typeof LoginWithEmailOTPEventEmit !== 'undefined'; 4518 4519 // Emit cancel event 4520 if (useEnums) { 4521 window.pendingAuth.handle.emit(LoginWithEmailOTPEventEmit.Cancel); 4522 } else { 4523 window.pendingAuth.handle.emit('cancel'); 4524 } 4525 4526 console.log('OTP login cancelled'); 4527 } 4528 4529 // Clean up 4530 window.pendingAuth = null; 4531 4532 // Hide any overlays 4533 hideEmailLoginOverlay(); 4534 hideOTPInputOverlay(); 4535 hideLoadingOverlay(); 4536 } catch (error) { 4537 console.error('Error cancelling OTP login:', error); 4538 } 4539} 4540 4541// Update OTP input for retry 4542function updateOTPInputForRetry() {
4543 const otpInput = document.getElementById('otp-input'); 4544 if (otpInput) { 4545 otpInput.value = ''; 4546 otpInput.focus(); 4547 4548 // Add a shake animation for visual feedback 4549 otpInput.classList.add('shake'); 4550 4551 // Remove the animation class after it completes 4552 setTimeout(() => { 4553 otpInput.classList.remove('shake'); 4554 }, 500); 4555 } 4556 4557 // Hide loading overlay if it's visible 4558 hideLoadingOverlay(); 4559} 4560 4561function isValidLoginEmail(value) { 4562 const trimmed = String(value || '').trim(); 4563 if (!trimmed || trimmed.indexOf(' ') >= 0) return false; 4564 const at = trimmed.indexOf('@'); 4565 if (at <= 0 || at !== trimmed.lastIndexOf('@')) return false; 4566 const local = trimmed.slice(0, at); 4567 const domain = trimmed.slice(at + 1); 4568 if (!local || !domain || domain.indexOf('.') < 1) return false; 4569 if (domain.startsWith('.') || domain.endsWith('.')) return false; 4570 return true; 4571} 4572 4573function showEmailLoginOverlay() { 4574 hideEmailLoginOverlay(); 4575 4576 return new Promise((resolve) => { 4577 const overlay = document.createElement('div'); 4578 overlay.id = 'email-login-overlay'; 4579 overlay.className = 'pe-auth-overlay'; 4580 4581 const content = document.createElement('div'); 4582 content.className = 'pe-auth-shell'; 4583 4584 const logo = document.createElement('img'); 4585 logo.src = 'https://cdn.neonsky.app/neon-sky-logo.png'; 4586 logo.alt = 'Neon Sky Logo'; 4587 logo.className = 'pe-auth-shell__logo'; 4588 4589 const description = document.createElement('p'); 4590 description.className = 'pe-auth-shell__desc'; 4591 description.textContent = 'Enter your email address to receive a verification code.'; 4592 4593 const form = document.createElement('form'); 4594 form.noValidate = true; 4595 form.onsubmit = function(e) { 4596 e.preventDefault(); 4597 const raw = input.value.trim(); 4598 if (!isValidLoginEmail(raw)) { 4599 input.classList.add('shake'); 4600 setTimeout(() => input.classList.remove('shake'), 500); 4601 return; 4602 } 4603 hideEmailLoginOverlay(); 4604 resolve(raw); 4605 }; 4606 4607 const input = document.createElement('input'); 4608 input.id = 'email-login-input'; 4609 input.type = 'email'; 4610 input.name = 'email'; 4611 input.placeholder = '[email protected]'; 4612 input.autocomplete = 'email'; 4613 input.inputMode = 'email'; 4614 input.required = true; 4615 input.className = 'pe-auth-shell__input pe-auth-shell__input--email'; 4616 const savedEmail = localStorage.getItem('hydra_auth_email'); 4617 if (savedEmail && isValidLoginEmail(savedEmail)) { 4618 input.value = savedEmail.trim(); 4619 } 4620 form.appendChild(input); 4621 4622 const buttonContainer = document.createElement('div'); 4623 buttonContainer.className = 'pe-auth-actions'; 4624 4625 const continueButton = document.createElement('button'); 4626 continueButton.type = 'submit'; 4627 continueButton.textContent = 'Continue'; 4628 continueButton.className = 'pe-btn pe-btn--primary'; 4629 buttonContainer.appendChild(continueButton); 4630 4631 const cancelButton = document.createElement('button'); 4632 cancelButton.type = 'button'; 4633 cancelButton.textContent = 'Cancel'; 4634 cancelButton.className = 'pe-btn'; 4635 cancelButton.onclick = function() { 4636 hideEmailLoginOverlay(); 4637 resolve(null); 4638 }; 4639 buttonContainer.appendChild(cancelButton); 4640 form.appendChild(buttonContainer); 4641 4642 content.appendChild(logo); 4643 content.appendChild(description); 4644 content.appendChild(form); 4645 overlay.appendChild(content); 4646 document.body.appendChild(overlay); 4647 4648 setTimeout(() => { 4649 input.focus(); 4650 if (input.value) { 4651 input.select(); 4652 } 4653 }, 100); 4654 }); 4655} 4656 4657function hideEmailLoginOverlay() { 4658 const overlay = document.getElementById('email-login-overlay'); 4659 if (overlay && overlay.parentNode) { 4660 overlay.parentNode.removeChild(overlay); 4661 } 4662} 4663 4664// Show OTP input overlay - improved version 4665function showOTPInputOverlay(email) { 4666 hideOTPInputOverlay(); 4667 hideEmailLoginOverlay(); 4668 4669 const overlay = document.createElement('div'); 4670 overlay.id = 'otp-input-overlay'; 4671 overlay.className = 'pe-auth-overlay'; 4672 4673 const content = document.createElement('div'); 4674 content.className = 'pe-auth-shell'; 4675 4676 const logo = document.createElement('img'); 4677 logo.src = 'https://cdn.neonsky.app/neon-sky-logo.png'; 4678 logo.alt = 'Neon Sky Logo'; 4679 logo.className = 'pe-auth-shell__logo'; 4680 4681 const description = document.createElement('p'); 4682 description.className = 'pe-auth-shell__desc'; 4683 const descPrefix = document.createTextNode("We've sent a verification code to "); 4684 const descEmail = document.createElement('strong'); 4685 descEmail.textContent = String(email || ''); 4686 const descSuffix = document.createTextNode('. Please check your email and enter the code below:'); 4687 description.appendChild(descPrefix); 4688 description.appendChild(descEmail); 4689 description.appendChild(document.createElement('br')); 4690 description.appendChild(descSuffix); 4691 4692 const form = document.createElement('form'); 4693 form.onsubmit = function(e) { 4694 e.preventDefault();
4695 const otpInput = document.getElementById('otp-input'); 4696 if (otpInput && otpInput.value) { 4697 submitOTP(otpInput.value); 4698 } 4699 }; 4700 4701 const input = document.createElement('input'); 4702 input.id = 'otp-input'; 4703 input.type = 'text'; 4704 input.placeholder = 'Enter code'; 4705 input.pattern = '[0-9]*'; 4706 input.inputMode = 'numeric'; 4707 input.autocomplete = 'one-time-code'; 4708 input.className = 'pe-auth-shell__input'; 4709 form.appendChild(input); 4710 4711 const buttonContainer = document.createElement('div'); 4712 buttonContainer.className = 'pe-auth-actions'; 4713 4714 const verifyButton = document.createElement('button'); 4715 verifyButton.type = 'submit'; 4716 verifyButton.textContent = 'Verify'; 4717 verifyButton.className = 'pe-btn pe-btn--primary'; 4718 buttonContainer.appendChild(verifyButton); 4719 4720 const cancelButton = document.createElement('button'); 4721 cancelButton.type = 'button'; 4722 cancelButton.textContent = 'Cancel'; 4723 cancelButton.className = 'pe-btn'; 4724 cancelButton.onclick = function() { 4725 cancelOTPLogin(); 4726 }; 4727 buttonContainer.appendChild(cancelButton); 4728 form.appendChild(buttonContainer); 4729 4730 const resendContainer = document.createElement('div'); 4731 resendContainer.className = 'pe-auth-resend'; 4732 4733 const resendText = document.createElement('span'); 4734 resendText.textContent = "Didn't receive the code? "; 4735 4736 const resendLink = document.createElement('a'); 4737 resendLink.textContent = 'Resend'; 4738 resendLink.href = '#'; 4739 resendLink.onclick = function(e) { 4740 e.preventDefault(); 4741 cancelOTPLogin(); 4742 setTimeout(() => { 4743 startOTPLogin(); 4744 }, 500); 4745 }; 4746 4747 resendContainer.appendChild(resendText); 4748 resendContainer.appendChild(resendLink); 4749 form.appendChild(resendContainer); 4750 4751 content.appendChild(logo); 4752 content.appendChild(description); 4753 content.appendChild(form); 4754 overlay.appendChild(content); 4755 document.body.appendChild(overlay); 4756 4757 setTimeout(() => { 4758 input.focus(); 4759 }, 100); 4760} 4761 4762// Hide OTP input overlay 4763function hideOTPInputOverlay() {
4764 const overlay = document.getElementById('otp-input-overlay'); 4765 if (overlay && overlay.parentNode) { 4766 overlay.parentNode.removeChild(overlay); 4767 } 4768} 4769 4770// Utility function to show loading overlay 4771function showLoadingOverlay(message = 'Loading...') { 4772 hideLoadingOverlay(); 4773 4774 const overlay = document.createElement('div'); 4775 overlay.id = 'loading-overlay'; 4776 overlay.className = 'pe-auth-overlay'; 4777 4778 const content = document.createElement('div'); 4779 content.className = 'pe-auth-shell'; 4780 4781 const loaderContainer = document.createElement('div'); 4782 loaderContainer.className = 'pe-auth-shell__loader'; 4783 loaderContainer.innerHTML = ` 4784 <svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 24 24"> 4785 <defs> 4786 <filter id="svgSpinnersGooeyBalls20"> 4787 <feGaussianBlur in="SourceGraphic" result="y" stdDeviation="1"/> 4788 <feColorMatrix in="y" result="z" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7"/> 4789 <feBlend in="SourceGraphic" in2="z"/> 4790 </filter> 4791 </defs> 4792 <g filter="url(#svgSpinnersGooeyBalls20)"> 4793 <circle cx="5" cy="12" r="4" fill="currentColor"> 4794 <animate attributeName="cx" calcMode="spline" dur="2s" keySplines=".36,.62,.43,.99;.79,0,.58,.57" repeatCount="indefinite" values="5;8;5"/> 4795 </circle> 4796 <circle cx="19" cy="12" r="4" fill="currentColor"> 4797 <animate attributeName="cx" calcMode="spline" dur="2s" keySplines=".36,.62,.43,.99;.79,0,.58,.57" repeatCount="indefinite" values="19;16;19"/> 4798 </circle> 4799 <animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/> 4800 </g> 4801 </svg> 4802 `; 4803 content.appendChild(loaderContainer); 4804 4805 const messageEl = document.createElement('p'); 4806 messageEl.className = 'pe-auth-shell__message'; 4807 messageEl.textContent = message; 4808 content.appendChild(messageEl); 4809 4810 overlay.appendChild(content); 4811 document.body.appendChild(overlay); 4812} 4813 4814// Utility function to hide loading overlay 4815function hideLoadingOverlay() { 4816 const overlay = document.getElementById('loading-overlay'); 4817 if (overlay && overlay.parentNode) { 4818 overlay.parentNode.removeChild(overlay); 4819 } 4820} 4821 4822// Utility function to show success message 4823function showSuccessMessage(message, duration = 3000) { 4824 const existingToast = document.getElementById('success-toast'); 4825 if (existingToast && existingToast.parentNode) { 4826 existingToast.parentNode.removeChild(existingToast); 4827 } 4828 4829 const toast = document.createElement('div'); 4830 toast.id = 'success-toast'; 4831 toast.className = 'pe-auth-toast'; 4832 toast.textContent = message; 4833 4834 document.body.appendChild(toast); 4835 4836 setTimeout(() => { 4837 if (toast.parentNode) { 4838 toast.parentNode.removeChild(toast); 4839 } 4840 }, duration); 4841} 4842 4843// Utility function to show error message 4844function showErrorMessage(message, duration = 5000) { 4845 const existingToast = document.getElementById('error-toast'); 4846 if (existingToast && existingToast.parentNode) { 4847 existingToast.parentNode.removeChild(existingToast); 4848 } 4849 4850 const toast = document.createElement('div'); 4851 toast.id = 'error-toast'; 4852 toast.className = 'pe-auth-toast pe-auth-toast--error'; 4853 toast.textContent = message; 4854 4855 document.body.appendChild(toast); 4856 4857 setTimeout(() => { 4858 if (toast.parentNode) { 4859 toast.parentNode.removeChild(toast); 4860 } 4861 }, duration); 4862} 4863 4864// UPDATED: Check authentication status with class-based approach 4865async function checkAuth() { 4866 try { 4867 if (!magic) { 4868 console.log('Magic not initialized, skipping auth check'); 4869 return; 4870 } 4871 4872 // Prevent multiple simultaneous auth checks 4873 if (window._checkingAuth) return; 4874 window._checkingAuth = true; 4875 4876 console.log('Checking auth status...'); 4877 4878 isAuthenticated = await magic.user.isLoggedIn(); 4879 console.log('isLoggedIn check:', isAuthenticated); 4880 4881 if (isAuthenticated) { 4882 // Add the authenticated class 4883 document.body.classList.add('hydra-authenticated'); 4884 4885 // Get user metadata 4886 userMetadata = await magic.user.getInfo(); 4887 console.log('User info:', userMetadata); 4888 4889 // Get ID token for API calls (JWT format) 4890 const rawDidToken = await magic.user.getIdToken(); 4891 console.log('ID token acquired, length:', rawDidToken.length); 4892
4893 // Do NOT write the raw DID token to localStorage here â checkAdminStatus will 4894 // receive the server-issued hydra token and store that instead, which is what 4895 // the save function needs. Writing the DID token now would overwrite a valid 4896 // hydra token that may already be stored from the OTP login flow. 4897 4898 // Check if user is admin; on success this updates both localStorage and didToken 4899 // with a proper hydra token via checkAdminStatus â TokenManager.storeToken 4900 await checkAdminStatus(rawDidToken); 4901 } else { 4902 // Magic has no session â still try Hydra OTP token so /_manage and return visits work 4903 const hydraOnly = localStorage.getItem('hydra_auth_token'); 4904 if (hydraOnly) { 4905 try { 4906 await checkAdminStatus(hydraOnly); 4907 } catch (e) { 4908 console.error('Hydra token restore after Magic logout failed:', e); 4909 } 4910 } else { 4911 resetAuthUI(); 4912 } 4913 } 4914 4915 // Release lock 4916 window._checkingAuth = false; 4917 } catch (error) { 4918 console.error('Error checking authentication:', error); 4919 window._checkingAuth = false; 4920 resetAuthUI(); 4921 } 4922} 4923 4924 4925async function checkAdminStatus(token) { 4926 try { 4927 console.log('Checking admin status...'); 4928 4929 // Add user email to headers if available 4930 let userEmail = null; 4931 if (window.userMetadata && window.userMetadata.email) { 4932 userEmail = window.userMetadata.email; 4933 console.log('Using email from userMetadata:', userEmail); 4934 } else { 4935 userEmail = localStorage.getItem('hydra_auth_email'); // Fallback to localStorage 4936 if (userEmail) console.log('Using email from localStorage:', userEmail); 4937 } 4938 4939 if (!token) { 4940 console.log('No token provided to checkAdminStatus'); 4941 return false; // Indicate failure 4942 } 4943 4944 // Format the token correctly for the API call 4945 let formattedToken = token; 4946 // Ensure Bearer prefix is added for Magic tokens, or use hydra: prefix 4947 if (!token.startsWith('hydra:') && !token.startsWith('Bearer ')) { 4948 formattedToken = `Bearer ${token}`; // Assume Magic/JWT needs Bearer 4949 } else if (token.startsWith('hydra:')) { 4950 formattedToken = `Bearer ${token}`; // API expects Bearer even for hydra tokens 4951 } 4952 // If it already starts with Bearer, use as is 4953 4954 // Get the API URL, handling preview mode 4955 const apiUrl = typeof getApiUrl === 'function' ? 4956 getApiUrl('/api/check-admin') : 4957 '/api/check-admin'; 4958 4959 console.log('Sending admin check request to:', apiUrl); 4960 4961 // Create request headers 4962 const headers = { 4963 'Authorization': formattedToken, 4964 'X-Hydra-Request': 'true' // Indicate this might be part of Hydra flow 4965 }; 4966 4967 if (userEmail) { 4968 headers['X-User-Email'] = userEmail; 4969 } 4970 4971 console.log('Admin check headers:', { 4972 'Authorization': `${formattedToken.substring(0, 20)}...`, 4973 'X-User-Email': userEmail || 'Not available' 4974 }); 4975 4976 // Add timeout to the fetch request 4977 const controller = new AbortController(); 4978 const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout 4979 4980 let data; 4981 try { 4982 const response = await fetch(apiUrl, { 4983 method: 'GET', 4984 headers: headers, 4985 signal: controller.signal 4986 }); 4987 4988 clearTimeout(timeoutId); // Clear timeout if fetch succeeds 4989 4990 console.log('Admin check response status:', response.status); 4991 4992 // Get the response as text first for better debugging 4993 const responseText = await response.text(); 4994 console.log('Admin check response length:', responseText.length); 4995 console.log('Admin check response text (first 100 chars):', responseText.substring(0, 100) + '...'); 4996 4997 4998 try { 4999 data = JSON.parse(responseText); 5000 console.log('Admin check data:', data); 5001 } catch (parseError) { 5002 console.error('Error parsing admin check response:', parseError); 5003 // Handle cases where the response might not be JSON (e.g., HTML error page) 5004 // For preview URLs, let's still assume admin to allow editing flow 5005 if (window.location.hostname === 'preview.neonsky.app') { 5006 console.log('Preview URL detected, assuming admin status due to non-JSON response'); 5007 isAdmin = true; 5008 document.body.classList.add('hydra-admin'); 5009 localStorage.setItem('hydra_is_admin', 'true'); 5010 forceShowEditUI(); // Attempt to show UI 5011 return true; // Indicate potential success for preview 5012 } 5013 throw new Error(`Failed to parse admin check response: ${responseText.substring(0, 100)}...`); 5014 } 5015 5016 if (!response.ok) { 5017 // Throw an error with details from the parsed JSON if available 5018 throw new Error(`Admin check failed: ${response.status} - ${data?.error || responseText.substring(0, 50)}`); 5019 } 5020 5021 // Process the admin status 5022 isAdmin = data.isAdmin; 5023 window.isAdmin = isAdmin; // Update global flag 5024 5025 // Update classes based on admin status 5026 if (isAdmin) { 5027 document.body.classList.add('hydra-admin'); 5028 localStorage.setItem('hydra_is_admin', 'true'); 5029 // Store email if we got it from the server response 5030 if (data.email) { 5031 localStorage.setItem('hydra_auth_email', data.email); 5032 } else if (userEmail) { 5033 localStorage.setItem('hydra_auth_email', userEmail); // Store the email we used 5034 } 5035 } else { 5036 document.body.classList.remove('hydra-admin'); 5037 localStorage.removeItem('hydra_is_admin'); 5038 localStorage.removeItem('hydra_auth_email'); // Clear email if not admin 5039 } 5040 5041 // Update siteId if provided 5042 if (data.siteId) { 5043 siteId = data.siteId; 5044 window.siteId = siteId; 5045 if (window.Parameters) window.Parameters.siteId = siteId; 5046 } 5047 5048 // Store new token if provided (Hydra token) 5049 if (data.hydraToken) { 5050 console.log('Received hydra token:', data.hydraToken.substring(0, 10) + '...'); 5051 const cleanHydraToken = data.hydraToken.startsWith('hydra:') ? data.hydraToken.substring(6) : data.hydraToken; 5052 // Always update the local didToken variable so saves immediately use the hydra token 5053 didToken = cleanHydraToken; 5054 // Store token globally and in localStorage using TokenManager if available 5055 if (window.TokenManager && typeof window.TokenManager.storeToken === 'function') { 5056 window.TokenManager.storeToken(data.hydraToken, data.email || userEmail || ''); 5057 } else { 5058 // Fallback storage 5059 localStorage.setItem('hydra_auth_token', cleanHydraToken); 5060 if (data.email || userEmail) { 5061 localStorage.setItem('hydra_auth_email', data.email || userEmail); 5062 } 5063 } 5064 console.log('Stored credentials in localStorage'); 5065 } 5066 5067 // Update UI based on admin status 5068 if (isAdmin) { 5069 console.log('Admin status confirmed, showing edit UI'); 5070 forceShowEditUI(); // Ensure UI elements are visible 5071 // --- MODIFICATION START --- 5072 // If layout is horizontal, re-render the menu to show controls 5073 let currentLayout = 'sidebar'; // Default 5074 if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings) { 5075 currentLayout = window.MenuStyleCustomizer.settings.menuLayout || 'sidebar'; 5076 } 5077 if (currentLayout === 'horizontal') { 5078 console.log('checkAdminStatus: Horizontal layout detected, re-rendering horizontal menu.'); 5079 if (typeof renderHorizontalMenu === 'function') { 5080 renderHorizontalMenu(); 5081 } else { 5082 console.warn("checkAdminStatus: renderHorizontalMenu function not found."); 5083 } 5084 } 5085 // --- MODIFICATION END --- 5086 createLoginBackup(); // Create backup after successful login/admin check 5087 } else { 5088 console.log('User is not admin, resetting UI.'); 5089 resetAuthUI(); // Ensure non-admin UI state 5090 } 5091 5092 return isAdmin; // Return the admin status 5093 5094 } catch (fetchError) { 5095 clearTimeout(timeoutId); // Clear timeout on error as well 5096 console.error('Fetch error during admin check:', fetchError); 5097 5098 // For preview URLs, still allow editing as a fallback 5099 if (window.location.hostname === 'preview.neonsky.app') { 5100 console.log('Preview URL + fetch error, still enabling editing'); 5101 isAdmin = true; 5102 window.isAdmin = true; 5103 document.body.classList.add('hydra-admin'); 5104 localStorage.setItem('hydra_is_admin', 'true'); 5105 forceShowEditUI(); 5106 return true; // Indicate potential success for preview 5107 } 5108 5109 // For non-preview, throw the error to indicate failure 5110 throw fetchError; 5111 } 5112 } catch (error) { 5113 console.error('Error in checkAdminStatus:', error); 5114 // Don't show alert here, let the calling function handle UI feedback 5115 // Reset UI to non-admin state on error 5116 resetAuthUI(); 5117 return false; // Indicate failure 5118 } 5119} 5120 5121// UPDATED: Force showing edit UI with class-based approach 5122function forceShowEditUI() { 5123 console.log('Forcing admin/auth state for UI'); 5124 5125 // Ensure global state flags are set 5126 isAuthenticated = true; 5127 isAdmin = true; 5128 window.isAuthenticated = true; 5129 window.isAdmin = true; 5130 5131 // Add admin/auth classes to body - CSS will handle button visibility 5132 document.body.classList.add('hydra-authenticated', 'hydra-admin'); 5133 5134 // Store admin status in localStorage 5135 localStorage.setItem('hydra_is_admin', 'true'); 5136 5137 // Show edit controls container ONLY if already in edit mode 5138 if (window.isInEditMode && window.isInEditMode()) {
5139 const editControls = document.querySelector('.edit-controls'); 5140 if (editControls) { 5141 editControls.classList.add('visible'); 5142 editControls.style.display = 'flex'; // Make sure container is flex 5143 } 5144 // Ensure controls inside items are visible if already editing 5145 document.querySelectorAll('.controls, .element-controls, .edit-btn, .delete-btn, .visibility-toggle').forEach(el => { 5146 if (el.closest('.sidebar.editing') || el.closest('.page-container.editing')) { 5147 el.style.display = 'flex'; 5148 } 5149 }); 5150 5151 // Show logout button in edit controls when in edit mode 5152 const logoutButton = document.getElementById('logoutButton'); 5153 if (logoutButton) { 5154 logoutButton.style.display = 'flex'; 5155 logoutButton.classList.add('visible'); 5156 } 5157 } else { 5158 // Ensure edit controls container is hidden if not in edit mode 5159 const editControls = document.querySelector('.edit-controls'); 5160 if (editControls) { 5161 editControls.classList.remove('visible'); 5162 editControls.style.display = 'none'; 5163 } 5164 5165 // Hide logout button when not in edit mode 5166 const logoutButton = document.getElementById('logoutButton'); 5167 if (logoutButton) { 5168 logoutButton.style.display = 'none'; 5169 logoutButton.classList.remove('visible'); 5170 } 5171 } 5172 5173 console.log('Admin/Auth classes set. CSS should handle button visibility.'); 5174} 5175 5176// UPDATED: Reset auth UI with class-based approach 5177function resetAuthUI() { 5178 document.body.classList.remove('hydra-authenticated'); 5179 document.body.classList.remove('hydra-admin'); 5180 5181 5182 const logoutButton = document.getElementById('logoutButton'); 5183 if (logoutButton) { 5184 logoutButton.style.display = 'none'; 5185 logoutButton.classList.remove('visible'); 5186 } 5187 5188 const editButton = document.getElementById('editButton'); 5189 if (editButton) { 5190 editButton.style.display = 'none'; 5191 editButton.classList.remove('visible'); 5192 } 5193 5194 // Remove admin status from localStorage 5195 localStorage.removeItem('hydra_is_admin'); 5196 localStorage.removeItem('hydra_auth_token'); 5197 5198 if (isEditing) { 5199 toggleEditMode(); // Exit edit mode if active 5200 } 5201} 5202 5203// Debug function - add to your code during testing 5204function debugUIClasses() { 5205 console.log('Current body classes:', document.body.className); 5206 console.log('hydra-initialized:', document.body.classList.contains('hydra-initialized')); 5207 console.log('hydra-authenticated:', document.body.classList.contains('hydra-authenticated')); 5208 console.log('hydra-admin:', document.body.classList.contains('hydra-admin')); 5209 console.log('edit-mode-active:', document.body.classList.contains('edit-mode-active')); 5210} 5211 5212 5213 5214async function logout() { 5215 console.log('Logout initiated'); 5216 5217 try { 5218 // Show loading indication (optional) 5219 showLoadingOverlay('Logging out...'); 5220 5221 // 1. Properly logout from Magic SDK 5222 if (window.magic) { 5223 try { 5224 await window.magic.user.logout(); 5225 console.log('Magic SDK logout successful'); 5226 } catch (magicError) { 5227 console.error('Error during Magic logout:', magicError); 5228 // Continue with other logout steps even if Magic SDK logout fails 5229 } 5230 } 5231 5232 // 2. Reset all authentication state variables 5233 clearHydraClientAuthStorage(); 5234 5235 // 3. Reset all UI state - remove authentication classes from body 5236 document.body.classList.remove('hydra-authenticated'); 5237 document.body.classList.remove('hydra-admin'); 5238 5239 // 5. If in edit mode, exit it first 5240 if (document.body.classList.contains('edit-mode-active')) { 5241 console.log('Exiting edit mode before logout'); 5242 // If toggleEditMode exists, call it to exit edit mode 5243 if (typeof toggleEditMode === 'function') { 5244 try { 5245 toggleEditMode(); 5246 } catch (editError) { 5247 console.error('Error exiting edit mode:', editError); 5248 } 5249 } 5250 5251 // Ensure edit mode class is removed regardless 5252 document.body.classList.remove('edit-mode-active'); 5253 } 5254 5255 // 6. Ensure all edit UI elements are hidden 5256 5257 // Hide edit and logout buttons 5258 const editButton = document.getElementById('editButton'); 5259 if (editButton) { 5260 editButton.classList.remove('visible'); 5261 editButton.style.display = 'none'; 5262 } 5263 5264 const logoutButton = document.getElementById('logoutButton'); 5265 if (logoutButton) { 5266 logoutButton.classList.remove('visible'); 5267 logoutButton.style.display = 'none'; 5268 } 5269 5270 // Hide edit controls
5271 const editControls = document.querySelector('.edit-controls'); 5272 if (editControls) { 5273 editControls.style.display = 'none'; 5274 editControls.classList.remove('visible'); 5275 } 5276 5277 // Hide sidebar header if it should be hidden when logged out 5278 const sidebarHeader = document.querySelector('.sidebar-header'); 5279 if (sidebarHeader) { 5280 sidebarHeader.style.display = 'none'; 5281 } 5282 5283 // Hide all element controls 5284 document.querySelectorAll('.controls, .element-controls, .edit-btn, .delete-btn, .visibility-toggle').forEach(el => { 5285 el.style.display = 'none'; 5286 }); 5287 5288 // 7. Reset editor state if applicable 5289 if (window.Parameters) { 5290 window.Parameters.isInEditor = false; 5291 } 5292 5293 // Hide any editing forms that might be open 5294 const forms = document.querySelectorAll('.add-form, .edit-form, .element-edit-form, #menuStyleEditor, #metadataEditor, #importClassicForm, #sidebarElementForm'); 5295 forms.forEach(form => { 5296 form.style.display = 'none'; 5297 if (form.classList.contains('visible')) { 5298 form.classList.remove('visible'); 5299 } 5300 }); 5301 5302 // Remove editing class from sidebar 5303 const sidebar = document.querySelector('.sidebar'); 5304 if (sidebar) { 5305 sidebar.classList.remove('editing'); 5306 } 5307 5308 // 8. Clear any page-specific state 5309 if (window.PageManager && typeof window.PageManager.clearCurrentPage === 'function') { 5310 window.PageManager.clearCurrentPage(); 5311 } 5312 5313 // 9. Notify any components that need to know about logout 5314 document.dispatchEvent(new CustomEvent('user-logout', { 5315 detail: { timestamp: Date.now() } 5316 })); 5317 5318 // 10. Show success message 5319 hideLoadingOverlay(); 5320 showSuccessMessage('Successfully logged out'); 5321 5322 console.log('Logout complete'); 5323 5324 // 11. Optional: Refresh the page after a short delay for a clean state 5325 // Uncomment the following lines if you want the page to refresh after logout 5326 /* 5327 setTimeout(() => { 5328 window.location.reload(); 5329 }, 1500); 5330 */ 5331 5332 } catch (error) { 5333 console.error('Error during logout process:', error); 5334 hideLoadingOverlay(); 5335 showErrorMessage('Error during logout. Please refresh the page.'); 5336 } 5337} 5338 5339// Ensure the logout button is properly connected to the logout function 5340document.addEventListener('DOMContentLoaded', function() { 5341 const logoutButton = document.getElementById('logoutButton'); 5342 if (logoutButton) { 5343 // Remove any existing event listeners to avoid duplicates 5344 const newButton = logoutButton.cloneNode(true); 5345 logoutButton.parentNode.replaceChild(newButton, logoutButton); 5346 5347 // Add fresh event listener 5348 newButton.addEventListener('click', function(e) { 5349 e.preventDefault(); 5350 e.stopPropagation(); 5351 logout(); 5352 }); 5353 } 5354}); 5355 5356function stopAutoAdvanceTimer() { 5357 if (window.currentAutoAdvanceTimerId) { 5358 clearTimeout(window.currentAutoAdvanceTimerId); 5359 window.currentAutoAdvanceTimerId = null; 5360 console.log('Auto-advance timer stopped.'); 5361 } 5362} 5363window.stopAutoAdvanceTimer = stopAutoAdvanceTimer; 5364 5365function beginHydraContentLoad(type, itemId, pageId) { 5366 const generation = (Number(window.__hydraContentLoadGeneration) || 0) + 1; 5367 window.__hydraContentLoadGeneration = generation; 5368 window.__neonGalleryInitEpoch = (Number(window.__neonGalleryInitEpoch) || 0) + 1; 5369 window.__hydraContentLoadOwner = { 5370 generation, 5371 type, 5372 itemId: itemId === undefined || itemId === null ? '' : String(itemId), 5373 pageId: pageId === undefined || pageId === null ? '' : String(pageId) 5374 }; 5375 return generation; 5376} 5377 5378function isHydraContentLoadCurrent(generation, type, itemId) { 5379 if (generation != null && Number(generation) !== Number(window.__hydraContentLoadGeneration || 0)) { 5380 return false; 5381 } 5382 if (type && window._activeContentType && window._activeContentType !== type) { 5383 return false; 5384 } 5385 if (itemId != null && itemId !== '' && window.activeGalleryId != null && 5386 String(window.activeGalleryId) !== String(itemId)) { 5387 return false; 5388 } 5389 return true; 5390} 5391 5392window.beginHydraContentLoad = beginHydraContentLoad; 5393window.isHydraContentLoadCurrent = isHydraContentLoadCurrent; 5394 5395function dismissGalleryPasswordOverlay() { 5396 if (window.PageManager && typeof window.PageManager.removeGalleryPasswordOverlay === 'function') { 5397 window.PageManager.removeGalleryPasswordOverlay(); 5398 return; 5399 } 5400 document.querySelectorAll('.gallery-password-protection-overlay').forEach(function(overlay) { 5401 if (overlay._hydraPasswordAbort && typeof overlay._hydraPasswordAbort.abort === 'function') { 5402 overlay._hydraPasswordAbort.abort(); 5403 } 5404 overlay.remove(); 5405 }); 5406} 5407window.dismissGalleryPasswordOverlay = dismissGalleryPasswordOverlay; 5408 5409function toggleEditMode() { 5410 // Prevent multiple simultaneous calls 5411 if (window._editModeToggleInProgress) { 5412 console.log('Edit mode toggle already in progress, skipping'); 5413 return; 5414 } 5415 window._editModeToggleInProgress = true; 5416 5417 console.log("toggleEditMode called. Current window.isEditing:", window.isEditing, "Current window.isInEditMode():", window.isInEditMode ? window.isInEditMode() : 'undefined'); 5418 5419 stopAutoAdvanceTimer(); // Assumes this function exists and stops any slideshow/auto-advance 5420 5421 // Authentication and Authorization Checks 5422 if (!window.isAuthenticated && !(localStorage.getItem('hydra_is_admin') === 'true')) { 5423 alert('You must be logged in to edit'); 5424 window._editModeToggleInProgress = false;
5425 return; 5426 } 5427 if (localStorage.getItem('hydra_is_admin') === 'true' && !window.isAdmin) { 5428 window.isAdmin = true; // Synchronize global flag 5429 } 5430 if (!window.isAdmin) { 5431 alert('You must be an admin to edit'); 5432 window._editModeToggleInProgress = false; 5433 return; 5434 } 5435 5436 const currentEditState = window.isInEditMode ? window.isInEditMode() : (typeof window.isEditing !== 'undefined' ? window.isEditing : false); 5437 const newEditState = !currentEditState; 5438 5439 console.log(`Changing edit mode from ${currentEditState} to: ${newEditState}`); 5440 5441 // Apply body class for edit mode styling 5442 if (newEditState) { 5443 console.log('Entering edit mode - setting body class and theme'); 5444 document.body.setAttribute('data-theme', 'pico'); // Optional: if Pico theme is used for edit mode 5445 document.body.classList.add('edit-mode-active'); 5446 } else { 5447 console.log('Exiting edit mode - removing body class and theme'); 5448 document.body.removeAttribute('data-theme'); 5449 document.body.classList.remove('edit-mode-active'); 5450 } 5451 5452 // Update the global state and dispatch event 5453 if (typeof window.updateGlobalEditState === 'function') { 5454 window.updateGlobalEditState(newEditState); 5455 } else { 5456 window.isEditing = newEditState; // Fallback 5457 document.dispatchEvent(new CustomEvent('edit-mode-changed', { detail: { editing: newEditState } })); 5458 if (!newEditState && typeof window.closeOptionsPanel === 'function') { 5459 window.closeOptionsPanel(); 5460 } 5461 } 5462 5463 if (document.body.classList.contains('menu-layout-horizontal') && typeof syncContentMarginToHorizontalHeader === 'function') { 5464 setTimeout(syncContentMarginToHorizontalHeader, 50); 5465 setTimeout(syncContentMarginToHorizontalHeader, 350); 5466 } 5467 5468 const sidebarEditControls = document.querySelector('.edit-controls'); 5469 5470 if (newEditState) { 5471 // Entering edit mode 5472 if (sidebarEditControls) { 5473 sidebarEditControls.style.display = 'flex'; 5474 sidebarEditControls.classList.add('visible'); 5475 } 5476 5477 // Show logout button in edit controls when entering edit mode 5478 const logoutButton = document.getElementById('logoutButton'); 5479 if (logoutButton) { 5480 logoutButton.style.display = 'flex'; 5481 logoutButton.classList.add('visible'); 5482 } 5483 5484 // Always render the tree-like menu in the sidebar for editing 5485 if (typeof window.renderGalleries === 'function') { 5486 console.log('toggleEditMode (Edit): Rendering sidebar menu (gallery tree) for editing.'); 5487 window.renderGalleries(); // This will show all items, including those with visible:false 5488 } 5489 5490 setTimeout(() => { 5491 if (typeof window.initializeNestedSortables === 'function') { 5492 console.log('Initializing nested sortables for edit mode'); 5493 window.initializeNestedSortables(); 5494 } 5495 if (window.SidebarManager && typeof window.SidebarManager._reinitializeNestedSortables === 'function') { 5496 window.SidebarManager._reinitializeNestedSortables(); 5497 } 5498 }, 150); 5499 5500 // If the currently active item was invisible, reload it now that we are in edit mode. 5501 if(window.galleries && window.activeGalleryId){ 5502 const currentGalleryItem = window.galleries.find(g => g.id === window.activeGalleryId); 5503 if (currentGalleryItem && currentGalleryItem.visible === false) { 5504 console.log('toggleEditMode (Edit): Active item was invisible, reloading it.'); 5505 if (currentGalleryItem.isPage && window.loadPage) { 5506 window.loadPage(window.activeGalleryId); 5507 } else if(window.loadGallery) { 5508 window.loadGallery(window.activeGalleryId); 5509 } 5510 } 5511 } 5512 if(typeof window.showStyleEditor === 'function' && 5513 !(window.MenuStyleCustomizer && window.MenuStyleCustomizer._overlayOpen)) { 5514 window.showStyleEditor(false); // Ensure style editor is hidden initially 5515 } 5516 5517 } else { 5518 // Exiting edit mode (going to Live view) 5519 if (sidebarEditControls) { 5520 sidebarEditControls.style.display = 'none'; 5521 sidebarEditControls.classList.remove('visible'); 5522 } 5523 5524 // Hide logout button when exiting edit mode 5525 const logoutButton = document.getElementById('logoutButton'); 5526 if (logoutButton) { 5527 logoutButton.style.display = 'none'; 5528 logoutButton.classList.remove('visible'); 5529 } 5530 if (window.MenuStyleCustomizer && typeof window.MenuStyleCustomizer._prepareOverlayForLiveView === 'function') { 5531 window.MenuStyleCustomizer._prepareOverlayForLiveView(); 5532 } 5533 if(typeof window.closeAllForms === 'function') window.closeAllForms(); 5534 5535 if (typeof window.destroyNestedSortables === 'function') { 5536 window.destroyNestedSortables(); 5537 } else if (window.sortableInstances && Array.isArray(window.sortableInstances)) {
5538 window.sortableInstances.forEach(instance => instance.destroy()); 5539 window.sortableInstances = []; 5540 } 5541 5542 // Re-render the menu based on the actual view mode layout. 5543 // These functions should internally handle not showing items with visible:false. 5544 let currentLayout = 'sidebar'; 5545 if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings) { 5546 currentLayout = window.MenuStyleCustomizer.settings.menuLayout || 'sidebar'; 5547 } 5548 if (currentLayout === 'horizontal' && typeof window.renderHorizontalMenu === 'function') { 5549 window.renderHorizontalMenu(); 5550 } else if (typeof window.renderGalleries === 'function') { 5551 window.renderGalleries(); 5552 } 5553 5554 // Re-render sidebar elements (social, text, etc.) so edit-only UI (grid, dashed slots) is replaced by live view 5555 if (window.SidebarManager && typeof window.SidebarManager._syncWithGlobalEditState === 'function' && typeof window.SidebarManager.renderElements === 'function') { 5556 window.SidebarManager._syncWithGlobalEditState(); 5557 window.SidebarManager.renderElements(); 5558 } 5559 5560 // Handle the currently active gallery/page. 5561 if (window.galleries && window.activeGalleryId) { 5562 const currentGalleryItem = window.galleries.find(g => g.id === window.activeGalleryId); 5563 if (currentGalleryItem) { 5564 // The content for currentGalleryItem should remain loaded, 5565 // even if currentGalleryItem.visible is false. 5566 // The menu rendering above will hide it from the list if it's invisible. 5567 5568 // Apply page-specific menu hiding rules. 5569 const bodyEl = document.body; 5570 // When exiting edit mode, isInEditMode() will effectively be false for this check. 5571 if (shouldApplyMenuHiddenForEmbedOrGallery(currentGalleryItem)) { 5572 bodyEl.classList.add('menu-hidden-on-page'); 5573 } else { 5574 bodyEl.classList.remove('menu-hidden-on-page'); 5575 } 5576 applyMenuOverlayClassForGallery(currentGalleryItem); 5577 // Ensure active states in the (potentially now filtered) menu are updated. 5578 // If the active item is invisible, it won't be marked active in the menu, which is fine. 5579 if(typeof window.updateActiveStates === 'function') window.updateActiveStates(); 5580 if(typeof window.updateActiveStatesHorizontal === 'function') window.updateActiveStatesHorizontal(); 5581 5582 } else { 5583 // activeGalleryId points to a non-existent item, so clear it and the content. 5584 console.log('toggleEditMode (Live): activeGalleryId points to a non-existent item. Clearing content.'); 5585 window.activeGalleryId = null; 5586 const galleryContainer = document.querySelector('.gallery-container'); 5587 if (galleryContainer) galleryContainer.innerHTML = ''; 5588 if(typeof window.updateActiveStates === 'function') window.updateActiveStates(); 5589 if(typeof window.updateActiveStatesHorizontal === 'function') window.updateActiveStatesHorizontal(); 5590 } 5591 } 5592 5593 // If, after all that, no content is effectively active (e.g., initial load to root, 5594 // or activeGalleryId became null because the item was deleted) 5595 // then try to load a default page (home page or first visible gallery). 5596 const galleryContainer = document.querySelector('.gallery-container'); 5597 // Check if activeGalleryId is null OR if it's set but the container is empty (e.g. item was deleted) 5598 const noActiveContent = window.activeGalleryId === null || 5599 (galleryContainer && galleryContainer.innerHTML.trim() === ''); 5600 5601 if (noActiveContent && window.galleries) { 5602 console.log("toggleEditMode (Live): No active content, attempting to load default page."); 5603 const homePage = window.galleries.find(g => g.isHomePage === true); 5604 let loadedDefault = false;
5605 if (homePage) { 5606 // Load home page regardless of its visibility status for this default loading logic 5607 console.log(`toggleEditMode (Live): Loading home page: ${homePage.title}`); 5608 if (homePage.isPage && window.loadPage) { window.loadPage(homePage.id); loadedDefault = true; } 5609 else if (window.loadGallery) { window.loadGallery(homePage.id); loadedDefault = true; } 5610 } 5611 5612 if (!loadedDefault) { 5613 // If no home page, load the first *visible* gallery/page in live view. 5614 const firstVisibleGallery = window.galleries.find(g => g.visible !== false && !g.isSpacer && !g.isFolder && !g.isSubmenu); 5615 if (firstVisibleGallery) { 5616 console.log(`toggleEditMode (Live): No home page, loading first visible item: ${firstVisibleGallery.title}`); 5617 if (firstVisibleGallery.isPage && window.loadPage) { 5618 window.loadPage(firstVisibleGallery.id); 5619 } else if(window.loadGallery) { 5620 window.loadGallery(firstVisibleGallery.id); 5621 } 5622 } else { 5623 console.log('toggleEditMode (Live): No home page and no visible items to load.'); 5624 } 5625 } 5626 } 5627 5628 if (window.MenuStyleCustomizer && 5629 window.MenuStyleCustomizer._overlayOpen && 5630 typeof window.MenuStyleCustomizer.applyStyles === 'function') { 5631 window.MenuStyleCustomizer.applyStyles(); 5632 } 5633 } 5634 window._editModeToggleInProgress = false; 5635} 5636 5637 5638 5639/** 5640 * Improved toggleSidebarElementForm - Closes other forms first 5641 */ 5642function toggleSidebarElementForm() { 5643 const formId = 'sidebarElementForm'; 5644 const form = document.getElementById(formId); 5645 5646 if (!form) return; 5647 5648 // If this form is already open, just close everything 5649 if (window.currentOpenForm === formId) { 5650 closeAllForms(); 5651 return; 5652 } 5653 5654 // Otherwise close all forms and open this one 5655 closeAllForms(); 5656 5657 // Show the form 5658 form.style.display = 'block'; 5659 form.classList.add('visible'); 5660 5661 // Set this as the current open form 5662 window.currentOpenForm = formId; 5663} 5664 5665// New function to add a sidebar element 5666function addSidebarElement() { 5667const typeInput = document.querySelector('input[name="sidebarElementType"]:checked'); 5668if (!typeInput) { 5669alert('Please select an element type'); 5670return; 5671} 5672 5673const type = typeInput.value; 5674 5675// Use the SidebarManager to add the element 5676if (window.SidebarManager) { 5677const newElementId = window.SidebarManager.addElement(type); 5678toggleSidebarElementForm(); 5679 5680if (newElementId) { 5681 // Save the changes to KV store 5682 saveGalleries().then(() => { 5683 console.log(`New ${type} element added successfully with ID: ${newElementId}`); 5684 }).catch(error => { 5685 console.error('Error saving new element:', error); 5686 }); 5687} 5688} else { 5689alert('SidebarManager not available'); 5690} 5691} 5692 5693 5694// Global variable to store metadata 5695window.siteMetadata = { 5696 title: '', 5697 description: '', 5698 googleAnalytics: '', 5699 noIndex: false, 5700 favicon: '', 5701 ogImageUrl: '', 5702 contentLanguage: 'en', // Default to English 5703 copyrightEnabled: false, 5704 copyrightText: '', 5705 customPublicSiteEnabled: false, 5706 customPublicSiteHtmlR2Key: '', 5707 customPublicSiteHtmlFilename: '', 5708 siteWideBasicAuth: undefined 5709}; 5710 5711// Global variable to store uploaded favicon URL temporarily 5712window.uploadedFaviconUrl = ''; 5713 5714function updateMetadataCopyrightFieldState() { 5715 const toggle = document.getElementById('metadataCopyrightEnabled'); 5716 const textInput = document.getElementById('metadataCopyrightText'); 5717 const isEnabled = !!(toggle && toggle.checked); 5718 5719 if (textInput) { 5720 textInput.disabled = !isEnabled; 5721 } 5722 5723 // Intentionally leave the surrounding form group in place; the disabled attribute on the input 5724 // provides the visual cue in PicoCSS. 5725} 5726 5727/** 5728 * Improved toggleMetadataEditor - Closes other forms first 5729 */ 5730function toggleMetadataEditor() { 5731 const formId = 'metadataEditor'; 5732 const editor = document.getElementById(formId); 5733 5734 if (!editor) return; 5735 5736 // If this form is already open, just close everything 5737 if (window.currentOpenForm === formId) { 5738 closeAllForms(); 5739 return; 5740 } 5741 5742 // Otherwise close all forms and open this one 5743 closeAllForms(); 5744 5745 // Load current values before showing 5746 loadMetadataValues(); 5747 editor.style.display = 'block'; 5748 5749 // Set this as the current open form 5750 window.currentOpenForm = formId; 5751 if (typeof syncCustomPublicSiteBypassVisibility === 'function') { 5752 syncCustomPublicSiteBypassVisibility(); 5753 } 5754} 5755 5756 5757// Load metadata values into the form 5758function loadMetadataValues() { 5759 // Get the current values from the global object 5760 const metadata = window.siteMetadata || {}; 5761 console.log('loadMetadataValues: Loading metadata:', JSON.stringify(metadata)); 5762 console.log('loadMetadataValues: Google Analytics from metadata:', metadata.googleAnalytics); 5763 5764 console.warn('=== FAVICON LOAD DEBUG ==='); 5765 console.warn('window.siteMetadata:', JSON.stringify(window.siteMetadata)); 5766 console.warn('metadata object:', JSON.stringify(metadata)); 5767 console.warn('metadata.favicon value:', metadata.favicon); 5768 console.warn('metadata.favicon type:', typeof metadata.favicon); 5769 5770 // Populate form fields 5771 document.getElementById('metaTitle').value = metadata.title || ''; 5772 document.getElementById('metaDescription').value = metadata.description || ''; 5773 document.getElementById('googleAnalytics').value = metadata.googleAnalytics || ''; 5774 document.getElementById('noIndexToggle').checked = metadata.noIndex || false; 5775 5776 // Schema / Identity fields 5777 const schemaType = metadata.schemaType || ''; 5778 const personRadio = document.getElementById('schemaTypePerson'); 5779 const orgRadio = document.getElementById('schemaTypeOrganization'); 5780 if (personRadio) personRadio.checked = schemaType === 'Person'; 5781 if (orgRadio) orgRadio.checked = schemaType === 'Organization'; 5782 const jobTitleEl = document.getElementById('schemaJobTitle'); 5783 if (jobTitleEl) jobTitleEl.value = metadata.schemaJobTitle || ''; 5784 const cityEl = document.getElementById('schemaCity'); 5785 if (cityEl) cityEl.value = metadata.schemaCity || ''; 5786 const emailEl = document.getElementById('schemaEmail'); 5787 if (emailEl) emailEl.value = metadata.schemaEmail || ''; 5788 const imageLicenseUrlEl = document.getElementById('imageLicenseUrl'); 5789 if (imageLicenseUrlEl) imageLicenseUrlEl.value = metadata.imageLicenseUrl || ''; 5790 const acquireLicensePageUrlEl = document.getElementById('acquireLicensePageUrl'); 5791 if (acquireLicensePageUrlEl) acquireLicensePageUrlEl.value = metadata.acquireLicensePageUrl || ''; 5792 if (typeof updateSchemaFields === 'function') updateSchemaFields(); 5793 5794 const copyrightToggle = document.getElementById('metadataCopyrightEnabled'); 5795 const copyrightInput = document.getElementById('metadataCopyrightText'); 5796 if (copyrightToggle) { 5797 const enabled = !!metadata.copyrightEnabled; 5798 copyrightToggle.checked = enabled; 5799 if (copyrightInput) { 5800 copyrightInput.value = metadata.copyrightText || ''; 5801 } 5802 } 5803 updateMetadataCopyrightFieldState(); 5804 5805 const customPubToggle = document.getElementById('customPublicSiteEnabled'); 5806 if (customPubToggle) { 5807 customPubToggle.checked = !!metadata.customPublicSiteEnabled; 5808 } 5809 const customPubKeyEl = document.getElementById('customPublicSiteHtmlR2Key'); 5810 if (customPubKeyEl) { 5811 customPubKeyEl.value = metadata.customPublicSiteHtmlR2Key || ''; 5812 } 5813 const customPubFileLabel = document.getElementById('customPublicSiteHtmlFilenameLabel'); 5814 if (customPubFileLabel) { 5815 customPubFileLabel.textContent = metadata.customPublicSiteHtmlFilename 5816 ? ('Current file: ' + metadata.customPublicSiteHtmlFilename) 5817 : 'No HTML file uploaded yet.'; 5818 } 5819 5820 const swAuth = metadata.siteWideBasicAuth; 5821 const swEnabled = !!(swAuth && swAuth.username && swAuth.password); 5822 const swEnableEl = document.getElementById('siteWideBasicAuthEnabled'); 5823 if (swEnableEl) swEnableEl.checked = swEnabled; 5824 const swUserEl = document.getElementById('siteWideBasicAuthUsername'); 5825 if (swUserEl) swUserEl.value = swAuth && swAuth.username ? swAuth.username : ''; 5826 const swPassEl = document.getElementById('siteWideBasicAuthPassword');
5827 if (swPassEl) swPassEl.value = swAuth && swAuth.password ? swAuth.password : ''; 5828 const swRealmEl = document.getElementById('siteWideBasicAuthRealm'); 5829 if (swRealmEl) swRealmEl.value = swAuth && swAuth.realm ? swAuth.realm : ''; 5830 if (typeof updateSiteWideBasicAuthFieldState === 'function') updateSiteWideBasicAuthFieldState(); 5831 5832 // Set content language dropdown (defaults to 'en') 5833 const contentLanguageSelect = document.getElementById('contentLanguage'); 5834 if (contentLanguageSelect) { 5835 contentLanguageSelect.value = metadata.contentLanguage || 'en'; 5836 console.log('Loaded content language:', contentLanguageSelect.value); 5837 } 5838 5839 const faviconInput = document.getElementById('faviconUrl'); 5840 if (faviconInput) { 5841 // Check if we have a recently uploaded favicon URL 5842 const faviconValue = metadata.favicon || window.uploadedFaviconUrl || ''; 5843 faviconInput.value = faviconValue; 5844 console.warn('Set favicon input value to:', faviconInput.value); 5845 console.warn('Used global uploaded favicon URL:', window.uploadedFaviconUrl); 5846 } else { 5847 console.error('Favicon input element not found!'); 5848 } 5849 5850 // Show favicon preview if there's an existing favicon 5851 if (metadata.favicon) { 5852 console.warn('Showing favicon preview for:', metadata.favicon); 5853 showFaviconPreview(metadata.favicon); 5854 } else { 5855 console.warn('No favicon to preview'); 5856 } 5857 5858 const ogInput = document.getElementById('ogImageUrl'); 5859 if (ogInput) { 5860 const ogVal = (metadata.ogImageUrl || '').trim(); 5861 ogInput.value = ogVal; 5862 } 5863 const ogBtnText = document.getElementById('uploadOgImageBtnText'); 5864 if (ogBtnText) { 5865 ogBtnText.textContent = (metadata.ogImageUrl || '').trim() ? 'Change image' : 'Upload image'; 5866 } 5867 if ((metadata.ogImageUrl || '').trim()) { 5868 showOgImagePreview(metadata.ogImageUrl.trim()); 5869 } else { 5870 const ogCont = document.getElementById('ogImagePreviewContainer'); 5871 if (ogCont) ogCont.innerHTML = ''; 5872 } 5873 5874 console.log('loadMetadataValues: Google Analytics field value after setting:', document.getElementById('googleAnalytics').value); 5875} 5876 5877// Save metadata values 5878function saveMetadata() { 5879 // Get values from form 5880 const googleAnalyticsElement = document.getElementById('googleAnalytics'); 5881 console.log('Google Analytics element found:', !!googleAnalyticsElement); 5882 console.log('Google Analytics element value:', googleAnalyticsElement ? googleAnalyticsElement.value : 'ELEMENT NOT FOUND'); 5883 5884 const faviconInput = document.getElementById('faviconUrl'); 5885 const faviconValue = faviconInput ? faviconInput.value : ''; 5886 const ogImageInput = document.getElementById('ogImageUrl'); 5887 const ogImageValue = ogImageInput ? (ogImageInput.value || '').trim() : ''; 5888 5889 const contentLanguageSelect = document.getElementById('contentLanguage'); 5890 const contentLanguageValue = contentLanguageSelect ? contentLanguageSelect.value : 'en'; 5891 5892 const copyrightToggle = document.getElementById('metadataCopyrightEnabled'); 5893 const copyrightInput = document.getElementById('metadataCopyrightText'); 5894 const copyrightEnabled = copyrightToggle ? copyrightToggle.checked : false; 5895 const copyrightText = copyrightInput ? copyrightInput.value.trim() : ''; 5896 5897 console.warn('=== FAVICON SAVE DEBUG ==='); 5898 console.warn('Favicon input element exists:', !!faviconInput); 5899 console.warn('Favicon input value:', faviconValue); 5900 console.warn('Favicon input value length:', faviconValue.length); 5901 5902 console.warn('=== CONTENT LANGUAGE SAVE ==='); 5903 console.warn('Content language selected:', contentLanguageValue); 5904 5905 const rawGa = googleAnalyticsElement ? googleAnalyticsElement.value : ''; 5906 // Normalize GA to ID only (client may not have normalizeGoogleAnalyticsId in scope) 5907 let gaId = (rawGa && typeof rawGa === 'string') ? rawGa.trim() : ''; 5908 if (gaId && !/^G-[A-Z0-9]+$/i.test(gaId) && !/^UA-d+-d+$/.test(gaId)) { 5909 const match = gaId.match(/(G-[A-Z0-9]+|UA-d+-d+)/i); 5910 gaId = match ? match[1] : ''; 5911 } 5912 const customPubKeyInput = document.getElementById('customPublicSiteHtmlR2Key'); 5913 const customPubFilename = document.getElementById('customPublicSiteHtmlFilenameStore'); 5914 const schemaPersonRadio = document.getElementById('schemaTypePerson'); 5915 const schemaOrgRadio = document.getElementById('schemaTypeOrganization'); 5916 const schemaType = schemaPersonRadio && schemaPersonRadio.checked ? 'Person' 5917 : schemaOrgRadio && schemaOrgRadio.checked ? 'Organization' 5918 : ''; 5919 const schemaJobTitleEl = document.getElementById('schemaJobTitle'); 5920 const schemaCityEl = document.getElementById('schemaCity'); 5921 const schemaEmailEl = document.getElementById('schemaEmail'); 5922 const imageLicenseUrlEl = document.getElementById('imageLicenseUrl'); 5923 const acquireLicensePageUrlEl = document.getElementById('acquireLicensePageUrl'); 5924 const imageLicenseUrlRaw = imageLicenseUrlEl ? imageLicenseUrlEl.value.trim() : ''; 5925 const acquireLicensePageUrlRaw = acquireLicensePageUrlEl ? acquireLicensePageUrlEl.value.trim() : ''; 5926 5927 const normalizePublicAbsoluteUrl = (value) => { 5928 if (!value) return ''; 5929 try { 5930 const parsed = new URL(value); 5931 if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return ''; 5932 return parsed.href; 5933 } catch { 5934 return ''; 5935 } 5936 }; 5937 const imageLicenseUrl = normalizePublicAbsoluteUrl(imageLicenseUrlRaw); 5938 const acquireLicensePageUrl = normalizePublicAbsoluteUrl(acquireLicensePageUrlRaw); 5939 if ((imageLicenseUrlRaw || acquireLicensePageUrlRaw) && (!imageLicenseUrl || !acquireLicensePageUrl)) { 5940 alert('Image licensing: enter two public http(s) URLs, or leave both fields empty.'); 5941 return; 5942 } 5943 5944 const siteWideBasicAuthEnabled = !!(document.getElementById('siteWideBasicAuthEnabled') && document.getElementById('siteWideBasicAuthEnabled').checked); 5945 const swUser = document.getElementById('siteWideBasicAuthUsername') ? document.getElementById('siteWideBasicAuthUsername').value.trim() : ''; 5946 const swPassEl = document.getElementById('siteWideBasicAuthPassword');
5947 const swPass = swPassEl ? swPassEl.value : ''; 5948 const swRealm = document.getElementById('siteWideBasicAuthRealm') ? document.getElementById('siteWideBasicAuthRealm').value.trim() : ''; 5949 5950 if (siteWideBasicAuthEnabled && (!swUser || !swPass)) { 5951 alert('Site-wide password: enter both username and password, or turn off the option.'); 5952 return; 5953 } 5954 5955 const metadata = { 5956 title: document.getElementById('metaTitle').value, 5957 description: document.getElementById('metaDescription').value, 5958 googleAnalytics: gaId, 5959 noIndex: document.getElementById('noIndexToggle').checked, 5960 favicon: faviconValue, 5961 ogImageUrl: ogImageValue, 5962 contentLanguage: contentLanguageValue, 5963 copyrightEnabled, 5964 copyrightText, 5965 customPublicSiteEnabled: !!(document.getElementById('customPublicSiteEnabled') && document.getElementById('customPublicSiteEnabled').checked), 5966 customPublicSiteHtmlR2Key: customPubKeyInput ? customPubKeyInput.value.trim() : '', 5967 customPublicSiteHtmlFilename: customPubFilename ? customPubFilename.value.trim() : '', 5968 schemaType: schemaType || undefined, 5969 schemaJobTitle: schemaJobTitleEl ? schemaJobTitleEl.value.trim() : '', 5970 schemaCity: schemaCityEl ? schemaCityEl.value.trim() : '',
5971 schemaEmail: schemaEmailEl ? schemaEmailEl.value.trim() : '', 5972 ...(imageLicenseUrl && acquireLicensePageUrl ? { imageLicenseUrl, acquireLicensePageUrl } : {}) 5973 }; 5974 5975 if (siteWideBasicAuthEnabled && swUser && swPass) { 5976 metadata.siteWideBasicAuth = { 5977 username: swUser, 5978 password: swPass, 5979 ...(swRealm ? { realm: swRealm } : {}) 5980 }; 5981 } 5982 5983 console.warn('Metadata object being created:', JSON.stringify(metadata)); 5984 console.warn('Favicon in metadata object:', metadata.favicon); 5985 5986 // Show normalized GA ID in the form so user sees what was saved (e.g. after pasting full snippet) 5987 if (googleAnalyticsElement && metadata.googleAnalytics !== rawGa.trim()) { 5988 googleAnalyticsElement.value = metadata.googleAnalytics; 5989 } 5990 5991 // Update global metadata object immediately 5992 window.siteMetadata = metadata; 5993 console.log("Updated window.siteMetadata:", JSON.stringify(window.siteMetadata)); 5994 if (window.SidebarManager && typeof window.SidebarManager.updateMetadataFooter === 'function') { 5995 window.SidebarManager.updateMetadataFooter(); 5996 } 5997 5998 // Ensure we have the latest galleries, sidebar elements, and styles 5999 // before constructing the customData object. 6000 6001 // Synchronize galleries first if the function exists 6002 if (typeof synchronizeGalleries === 'function') { 6003 synchronizeGalleries(); 6004 console.log('Galleries synchronized within saveMetadata'); 6005 } 6006 6007 // Retrieve current galleries (use window.galleries as the source of truth after sync) 6008 const currentGalleries = window.galleries || galleries || []; 6009 console.log(`Current galleries count before constructing customData: ${currentGalleries.length}`); 6010 6011 // Retrieve current sidebar elements 6012 const currentSidebarElements = window.SidebarManager && typeof window.SidebarManager.getElementsForSave === 'function' 6013 ? window.SidebarManager.getElementsForSave() 6014 : null; 6015 6016 // Retrieve current menu styles 6017 const currentMenuStyles = window.MenuStyleCustomizer ? window.MenuStyleCustomizer.settings : {}; 6018 6019 // Now construct customData using the retrieved current state 6020 if (window.saveGalleries) { 6021 const customData = { 6022 galleries: currentGalleries, // Use the synchronized/retrieved galleries 6023 siteMetadata: metadata, // Use the metadata gathered from the form 6024 menuStyles: currentMenuStyles // Use the retrieved menu styles 6025 }; 6026 if (Array.isArray(currentSidebarElements) && currentSidebarElements.length > 0) { 6027 customData.sidebarElements = currentSidebarElements; 6028 } 6029 6030 6031 console.log(`Saving metadata with preserved galleries: ${customData.galleries.length}`); 6032 console.log("Metadata being saved:", JSON.stringify(metadata)); 6033 console.log("Google Analytics specifically:", metadata.googleAnalytics); 6034 console.log("Sidebar elements being saved:", Array.isArray(customData.sidebarElements) ? customData.sidebarElements.length : '(omitted)'); 6035 console.log("Menu styles being saved:", JSON.stringify(customData.menuStyles)); 6036 6037 // Save with our correctly constructed custom data object 6038 window.saveGalleries(customData) 6039 .then(() => { 6040 alert('Metadata saved successfully'); 6041 toggleMetadataEditor(); // Hide the editor 6042 }) 6043 .catch(error => { 6044 console.error('Error saving metadata:', error); 6045 alert('Error saving metadata. Please try again.'); 6046 }); 6047 } else { 6048 alert('Save function not available'); 6049 } 6050} 6051 6052 6053// Function to add new entries to the style editor 6054function showStyleEditor(display = false) { 6055 if (display && window.MenuStyleCustomizer && typeof window.MenuStyleCustomizer.openOverlay === 'function') { 6056 window.MenuStyleCustomizer.openOverlay(); 6057 window.currentOpenForm = 'menuStyleEditor'; 6058 return; 6059 } 6060 hideStyleEditor(); 6061} 6062 6063// New function to hide the style editor 6064function hideStyleEditor() { 6065 if (window.MenuStyleCustomizer && typeof window.MenuStyleCustomizer.closeOverlay === 'function') { 6066 window.MenuStyleCustomizer.closeOverlay(); 6067 } 6068 const styleEditor = document.getElementById('menuStyleEditor'); 6069 if (styleEditor) { 6070 styleEditor.style.display = 'none'; 6071 } 6072} 6073 6074 6075/** 6076 * Improved toggleAddForm - Closes other forms first and ensures proper sizing 6077 */ 6078function toggleAddForm() { 6079 const formId = 'addForm'; 6080 const form = document.getElementById(formId); 6081 6082 if (!form) return; 6083 6084 // If this form is already open, just close everything 6085 if (window.currentOpenForm === formId) { 6086 closeAllForms(); 6087 return; 6088 } 6089 6090 // Otherwise close all forms and open this one 6091 closeAllForms(); 6092 6093 // Determine the appropriate max-height based on content 6094 // First check if we're showing the page form by checking the radio button 6095 const isPageSelected = document.querySelector('input[name="galleryType"][value="page"]')?.checked; 6096 6097 // Allow extra space for the page form 6098 const maxHeight = isPageSelected ? '600px' : '500px'; 6099 6100 // Show the form with proper max height 6101 form.style.maxHeight = maxHeight; 6102 form.style.overflow = 'visible';
6102 // Ensure all content is visible 6103 form.classList.add('visible'); 6104 6105 // Also add inline styles to ensure visibility during animation 6106 form.style.padding = '15px'; 6107 6108 // Force recalculation of max-height after UI is visible 6109 setTimeout(() => { 6110 // Get actual content height + padding 6111 const contentHeight = form.scrollHeight + 30; // Add padding 6112 6113 // Set max-height based on content with minimum of 500px 6114 form.style.maxHeight = Math.max(contentHeight, 500) + 'px'; 6115 6116 console.log(`Setting add form max height to ${form.style.maxHeight}`); 6117 }, 50); 6118 6119 // Populate parent options 6120 const parentSelect = document.getElementById('galleryParent'); 6121 if (parentSelect) { 6122 parentSelect.innerHTML = '<option value="">None (Top Level)</option>'; 6123 6124 // Add all galleries that could be parents (including submenus) 6125 galleries.forEach(gallery => { 6126 parentSelect.innerHTML += `<option value="${gallery.id}">${gallery.title}</option>`; 6127 }); 6128 } 6129 6130 // Set this as the current open form 6131 window.currentOpenForm = formId; 6132} 6133 6134 6135// Store the currently open form ID 6136window.currentOpenForm = null; 6137 6138/** 6139 * Close a child panel opened on top of the Style Customizer (palette explorer, custom code, color mixer). 6140 * Returns true when a child was closed so callers can keep the Style Customizer open. 6141 */ 6142function closeTopmostStyleCustomizerChildOverlay() { 6143 if (document.querySelector('.pe-palette-explorer-overlay')) { 6144 if (window.PaletteExplorer && typeof window.PaletteExplorer.close === 'function') { 6145 const wasOpen = !!document.querySelector('.pe-palette-explorer-overlay'); 6146 window.PaletteExplorer.close(); 6147 if (wasOpen && document.querySelector('.pe-palette-explorer-overlay')) { 6148 return false; 6149 } 6150 return wasOpen; 6151 } 6152 } 6153 if (document.querySelector('.pe-style-history-overlay, .sc-style-history-overlay') && 6154 window.MenuStyleCustomizer && 6155 typeof window.MenuStyleCustomizer.closeStyleHistoryOverlay === 'function') { 6156 window.MenuStyleCustomizer.closeStyleHistoryOverlay(); 6157 return true; 6158 } 6159 if (document.querySelector('.pe-font-explorer-overlay') && 6160 window.MenuStyleCustomizer && 6161 typeof window.MenuStyleCustomizer.closeFontExplorer === 'function') { 6162 window.MenuStyleCustomizer.closeFontExplorer(); 6163 return true; 6164 } 6165 if (document.querySelector('.pe-custom-code-overlay') && 6166 window.MenuStyleCustomizer && 6167 typeof window.MenuStyleCustomizer.closeCustomCodeOverlay === 'function') { 6168 window.MenuStyleCustomizer.closeCustomCodeOverlay(); 6169 return true; 6170 } 6171 const colorMixer = document.getElementById('colorMixerPopup'); 6172 if (colorMixer && colorMixer.classList.contains('is-open')) { 6173 colorMixer.classList.remove('is-open'); 6174 return true; 6175 } 6176 return false; 6177} 6178 6179/** 6180 * Close all editor forms with improved handling for add form 6181 */ 6182function closeAllForms() { 6183 if (typeof closeTopmostStyleCustomizerChildOverlay === 'function') { 6184 while (closeTopmostStyleCustomizerChildOverlay()) { 6185 // Close nested Style Customizer panels (palette, custom code, color mixer) first. 6186 } 6187 } 6188 6189 const keepMinimizedStyleCustomizer = !!(window.MenuStyleCustomizer && 6190 window.MenuStyleCustomizer._overlayOpen && 6191 window.MenuStyleCustomizer._overlayMinimized); 6192 6193 if (!keepMinimizedStyleCustomizer && 6194 window.MenuStyleCustomizer && 6195 typeof window.MenuStyleCustomizer.closeOverlay === 'function') { 6196 const styleOverlayWasOpen = window.MenuStyleCustomizer._overlayOpen; 6197 window.MenuStyleCustomizer.closeOverlay(); 6198 if (styleOverlayWasOpen && window.MenuStyleCustomizer._overlayOpen) { 6199 return; 6200 } 6201 } 6202 6203 // Add Form - Special handling to ensure proper animation 6204 const addForm = document.getElementById('addForm'); 6205 if (addForm) { 6206 addForm.classList.remove('visible'); 6207 6208 // Also force style reset after animation completes 6209 setTimeout(() => { 6210 if (!addForm.classList.contains('visible')) { 6211 addForm.style.maxHeight = '0'; 6212 addForm.style.overflow = 'hidden'; 6213 addForm.style.padding = '0 15px'; 6214 } 6215 }, 350); // Set slightly longer than CSS transition 6216 } 6217 6218 // Style Editor (do not hide the overlay host while the minimized mutate bar is live) 6219 const styleEditor = document.getElementById('menuStyleEditor'); 6220 if (styleEditor && !keepMinimizedStyleCustomizer) { 6221 styleEditor.style.display = 'none'; 6222 } 6223 6224 // Metadata Editor 6225 const metadataEditor = document.getElementById('metadataEditor'); 6226 if (metadataEditor) { 6227 metadataEditor.style.display = 'none'; 6228 } 6229 if (typeof syncCustomPublicSiteBypassVisibility === 'function') { 6230 syncCustomPublicSiteBypassVisibility(); 6231 } 6232 6233 // Sidebar Element Form 6234 const sidebarElementForm = document.getElementById('sidebarElementForm'); 6235 if (sidebarElementForm) { 6236 sidebarElementForm.style.display = 'none'; 6237 sidebarElementForm.classList.remove('visible'); 6238 } 6239 6240 // Import Classic Form 6241 const importClassicForm = document.getElementById('importClassicForm'); 6242 if (importClassicForm) { 6243 importClassicForm.style.display = 'none'; 6244 importClassicForm.classList.remove('visible'); 6245 } 6246 6247 // Clear the current open form 6248 window.currentOpenForm = null; 6249} 6250function downloadJson() { 6251 const data = JSON.stringify(galleries, null, 2); 6252 const blob = new Blob([data], { type: 'application/json' }); 6253 const url = URL.createObjectURL(blob); 6254 const a = document.createElement('a'); 6255 a.href = url; 6256 a.download = 'galleries.json'; 6257 document.body.appendChild(a); 6258 a.click(); 6259 document.body.removeChild(a); 6260 URL.revokeObjectURL(url); 6261} 6262 6263let galleriesBackup = []; 6264 6265 6266 6267/** 6268 * Improved version of initializeNestedSortables with a better nesting approach 6269 */ 6270function initializeNestedSortables() { 6271 console.log('Initializing nested sortables with improved nesting logic...'); 6272 6273 // Destroy leftover instances before binding again. A second Sortable on the 6274 // same list makes items preview during drag and snap back on release. 6275 document.querySelectorAll('#galleryTree .nested-sortable').forEach((el) => { 6276 try { 6277 const existing = typeof Sortable !== 'undefined' && Sortable.get ? Sortable.get(el) : null; 6278 if (existing && typeof existing.destroy === 'function') existing.destroy(); 6279 } catch (error) { 6280 console.warn('Could not destroy existing nested sortable:', error); 6281 } 6282 }); 6283 6284 // Create a backup of galleries 6285 try { 6286 galleriesBackup = JSON.parse(JSON.stringify(galleries)); 6287 } catch (error) { 6288 console.error('Error creating galleries backup:', error); 6289 galleriesBackup = []; 6290 } 6291 6292 // Find all nested sortable lists 6293 const nestedSortables = document.querySelectorAll('.nested-sortable'); 6294 if (nestedSortables.length === 0) { 6295 console.warn('No nested sortable elements found! Check your HTML
6295structure.'); 6296 return; 6297 } 6298 6299 // Helper: compute depth based on ancestor nested lists 6300 function computeDepth(liEl) { 6301 let depth = 0; 6302 let node = liEl; 6303 while (node) { 6304 const parentOl = node.closest('ol.nested-sortable'); 6305 if (!parentOl) break; 6306 const parentLi = parentOl.closest('li'); 6307 if (parentLi) { 6308 depth += 1; 6309 node = parentLi; 6310 } else { 6311 break; 6312 } 6313 } 6314 // Top-level depth should be 0 6315 return Math.max(depth - 1, 0); 6316 } 6317 6318 // Helper: apply indentation styling to an li and its subtree 6319 function applyIndentationFromDepth(rootLi) { 6320 if (!rootLi) return; 6321 const applyToItem = (li) => { 6322 const depth = computeDepth(li); 6323 // Update data-nesting-level attribute 6324 li.setAttribute('data-nesting-level', depth); 6325 // Apply padding-left style 6326 const content = li.querySelector('.gallery-item-content'); 6327 if (content) { 6328 content.style.paddingLeft = (depth * 16) + 'px'; 6329 } 6330 }; 6331 applyToItem(rootLi); 6332 rootLi.querySelectorAll('li').forEach(applyToItem); 6333 } 6334 6335 // Helper: ensure parent has proper state and nested list 6336 function ensureParentChildState(parentLi) { 6337 if (!parentLi) return null; 6338 parentLi.classList.add('has-children'); 6339 let nestedList = parentLi.querySelector('ol.nested-sortable'); 6340 if (!nestedList) { 6341 nestedList = document.createElement('ol'); 6342 nestedList.className = 'nested-sortable'; 6343 parentLi.appendChild(nestedList); 6344 } 6345 return nestedList; 6346 } 6347 6348 // Helper: clean parent if it has no children 6349 function cleanupParentIfEmpty(parentLi) { 6350 if (!parentLi) return; 6351 const nestedList = parentLi.querySelector(':scope > ol.nested-sortable'); 6352 const parentId = parseInt(parentLi.dataset && parentLi.dataset.id, 10); 6353 const parentItem = !isNaN(parentId) ? galleries.find(g => g.id === parentId) : null; 6354 // Opted-in pages/galleries must keep an empty drop zone and chevron. 6355 if (isNavigableMenuParent(parentItem)) { 6356 parentLi.classList.add('has-children'); 6357 return; 6358 } 6359 if (nestedList && nestedList.children.length === 0) { 6360 nestedList.remove(); 6361 parentLi.classList.remove('has-children'); 6362 } 6363 } 6364 6365 // Initialize each sortable with improved nesting settings 6366 let sortableInstances = []; 6367 6368 for (let i = 0; i < nestedSortables.length; i++) { 6369 try { 6370 const container = nestedSortables[i]; 6371 6372 // Track where the placeholder indicates the item should be placed 6373 // This helps us detect when SortableJS incorrectly auto-nests based on mouse position 6374 let intendedPlacementContainer = null; 6375 let intendedRelatedElement = null; 6376 6377 // Track last move to prevent oscillation 6378 let lastMoveTime = 0; 6379 let lastMoveRelatedId = null; 6380 const MOVE_DEBOUNCE_MS = 50; // Minimum time between moves (in milliseconds) 6381 6382 // Track if we're dragging the last item in a folder (to prevent boundary oscillation) 6383 let isLastItemInFolder = false; 6384 let draggedItemOriginalContainer = null; 6385 6386 // Track container changes to prevent rapid switching (hysteresis) 6387 let firstPositionFixLoopActive = false; // Prevent multiple fix loops 6388 let lastContainerDecision = null; // 'inside' or 'outside' 6389 let lastContainerDecisionTime = 0; 6390 const CONTAINER_SWITCH_DEBOUNCE_MS = 150; // Longer debounce for container switches 6391 6392 const sortable = new Sortable(container, { 6393 group: 'nested', 6394 animation: 150, // Reduced animation during drag to reduce lag 6395 easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)', // Smooth easing function 6396 fallbackOnBody: false, // Disable forced fallback on desktop for better performance 6397 forceFallback: false, // Use native drag on desktop 6398 6399 // CRITICAL: These settings make nesting much easier 6400 // Higher swap threshold makes it easier to drag over items 6401 swapThreshold: 0.65, // Increased to reduce oscillation within same folder 6402 6403 // IMPROVED NESTING SETTINGS 6404 // Used when determining if an item should be nested 6405 invertSwap: false, // Disable to reduce oscillation 6406 direction: 'vertical', // Lock direction to vertical to reduce oscillation 6407 6408 // Use handle for dragging 6409 handle: '.nested-sortable-handle', 6410 6411 // Filter out only the toggle button - this prevents SortableJS from intercepting clicks on it 6412 // The folder text (.menu-item.folder) is NOT filtered, so it can be used for both: 6413 // - Quick click â toggles folder (onclick handler fires before drag starts) 6414 // - Click and drag â drags the item (SortableJS handles the drag) 6415 // The delay helps distinguish between click and drag, but on desktop with delayOnTouchOnly: true, 6416 // the onclick should fire quickly enough before SortableJS intercepts 6417 filter: '.submenu-toggle, .submenu-toggle *, .toggle-icon', 6418 preventOnFilter: true, // Prevent drag when clicking filtered elements (toggle button only) 6419 6420 // Distinguish between click and drag 6421 delay: 120, 6422 delayOnTouchOnly: true, 6423 6424 // Empty container settings 6425 emptyInsertThreshold: 10, 6426 6427 // Add custom ghost class 6428 ghostClass: 'sortable-ghost', 6429 chosenClass: 'sortable-chosen', 6430 dragClass: 'sortable-drag', 6431 6432 // Make sure we listen to external move events 6433 dragoverBubble: true, 6434 6435 // Better drag preview stability 6436 scroll: true, 6437 scrollSensitivity: 100, 6438 scrollSpeed: 20, 6439 6440 // Reduce oscillation by making placeholder movement less sensitive 6441 forceFallback: false, // Use native drag for better performance 6442 fallbackTolerance: 0, // No tolerance - use exact position 6443 6444 /** 6445 * NESTING LOGIC: Prevent auto-nesting based on hover 6446 * Instead, rely on SortableJS's placeholder position which is accurate 6447 * We'll verify and correct placement in onEnd based on actual DOM position 6448 */ 6449 onMove: function(evt, originalEvent) { 6450 const dragged = evt.dragged; 6451 const related = evt.related; 6452 6453 if (!dragged || !related) return true; 6454 6455 // Safety check: Ensure items have data-id attributes 6456 // This can be missing if item was just added and DOM not fully updated 6457 if (!dragged.dataset.id || !related.dataset.id) { 6458 console.warn('â ï¸ [Drag] Item missing data-id attribute, aborting drag', { 6459 draggedHasId: !!dragged.dataset.id, 6460 relatedHasId: !!related.dataset.id 6461 }); 6462 return false;
6463 } 6464 6465 // Get the IDs to check for circular references 6466 const draggedId = parseInt(dragged.dataset.id); 6467 const relatedId = parseInt(related.dataset.id); 6468 6469 // Don't allow dropping inside itself or its descendants 6470 if (draggedId === relatedId || hasAncestor(relatedId, draggedId)) { 6471 return false; 6472 } 6473 6474 const currentTime = Date.now(); 6475 const rootContainer = document.querySelector('#galleryTree > ol.nested-sortable'); 6476 const draggedParent = draggedItemOriginalContainer || dragged.parentElement; 6477 6478 // Determine if we're moving inside or outside a folder 6479 // IMPORTANT: Check if related element IS the folder item itself (parent li) 6480 // When targeting first position, SortableJS might use the folder item as related 6481 const isRelatedFolderItem = related.classList && related.classList.contains('has-children'); 6482 6483 // CRITICAL: Handle empty folder case FIRST - before any other logic 6484 // When dragging into an empty folder, related is the folder item itself 6485 // This is essentially the same as targeting first position, but for an empty folder 6486 // We need to allow this immediately, bypassing all debounce and boundary checks 6487 if (isRelatedFolderItem) { 6488 const folderNestedSortable = related.querySelector(':scope > ol.nested-sortable'); 6489 const relatedIsNavigableParent = related.classList.contains('navigable-parent-item'); 6490 // Navigable parents look like normal links. Only treat them as an 6491 // empty folder when the pointer is actually over the empty list. 6492 let allowEmptyFolderInsert = !!(folderNestedSortable && folderNestedSortable.children.length === 0); 6493 if (allowEmptyFolderInsert && relatedIsNavigableParent && originalEvent) { 6494 const listRect = folderNestedSortable.getBoundingClientRect(); 6495 const pointerY = originalEvent.clientY; 6496 allowEmptyFolderInsert = pointerY >= listRect.top - 6 && pointerY <= listRect.bottom + 10; 6497 } 6498 if (allowEmptyFolderInsert) { 6499 // Empty folder detected - this is a drop into empty folder (first position) 6500 // Always allow it, bypass all debounce and boundary logic 6501 lastMoveTime = currentTime; 6502 lastMoveRelatedId = relatedId; 6503 intendedPlacementContainer = folderNestedSortable; 6504 intendedRelatedElement = null; // No related element in empty folder 6505 // Mark as moving inside folder for tracking 6506 lastContainerDecision = 'inside'; 6507 lastContainerDecisionTime = currentTime; 6508 6509 // CRITICAL: Manually move placeholder into empty folder's nested-sortable 6510 // SortableJS might not do this automatically for empty containers 6511 requestAnimationFrame(() => { 6512 const placeholder = document.querySelector('.sortable-ghost'); 6513 if (placeholder && placeholder.parentElement !== folderNestedSortable) { 6514 // Move placeholder into the empty folder 6515 folderNestedSortable.appendChild(placeholder); 6516 } 6517 6518 // Update ghost indentation for empty folder 6519 const ghost = document.querySelector('.sortable-ghost'); 6520 if (ghost) { 6521 const ghostContent = ghost.querySelector('.gallery-item-content'); 6522 if (ghostContent) { 6523 // Calculate depth from parent folder 6524 const parentFolder = folderNestedSortable.closest('li.has-children'); 6525 if (parentFolder) { 6526 const targetDepth = computeDepth(parentFolder) + 1; 6527 ghostContent.style.paddingLeft = (targetDepth * 16) + 'px'; 6528 } 6529 } 6530 } 6531 }); 6532 6533 // Return true immediately to allow the move 6534 return true; 6535 } 6536 } 6537 6538 let actualRelatedParent = related.parentElement; 6539 6540 // If related IS the folder item, we need to get its nested sortable container 6541 if (isRelatedFolderItem) { 6542 const nestedSortable = related.querySelector('ol.nested-sortable'); 6543 if (nestedSortable) { 6544 actualRelatedParent = nestedSortable; 6545 } 6546 } 6547 6548 const relatedParent = actualRelatedParent; 6549 6550 const isMovingInsideFolder = relatedParent && 6551 relatedParent.classList.contains('nested-sortable') && 6552 relatedParent !== rootContainer; 6553 const isMovingOutsideFolder = !isRelatedFolderItem && relatedParent === rootContainer; 6554 const currentDecision = isMovingInsideFolder ? 'inside' : (isMovingOutsideFolder ? 'outside' : null); 6555 6556 // Determine if we're moving within the same folder - this is the key check 6557 // If related IS the folder item, we're still moving within the same folder 6558 let isMovingWithinSameFolder = (isMovingInsideFolder && 6559 draggedParent && 6560 draggedParent.classList.contains('nested-sortable') && 6561 draggedParent !== rootContainer && 6562 relatedParent === draggedParent) || 6563 // OR: if related is the folder item itself and we're dragging from that folder 6564 (isRelatedFolderItem && 6565 draggedParent && 6566 draggedParent.classList.contains('nested-sortable') && 6567 draggedParent !== rootContainer && 6568 related.querySelector('ol.nested-sortable') === draggedParent); 6569 6570 // Special case: If related is the folder item, we're definitely trying to move within that folder 6571 // This happens when targeting first position - SortableJS uses the folder item as related 6572 if (isRelatedFolderItem && draggedParent && draggedParent.classList.contains('nested-sortable') && draggedParent !== rootContainer) { 6573 const folderNestedSortable = related.querySelector('ol.nested-sortable'); 6574 if (folderNestedSortable === draggedParent) { 6575 // We're dragging within the folder, and related is the folder item 6576 // This means we're targeting first position - FORCE it to be treated as same-folder move 6577 // FORCE same-folder detection - override any previous calculation 6578 isMovingWithinSameFolder = true; 6579 } 6580 } 6581 6582 // DEBUG: Log placeholder position changes ONLY when dragging from within a folder AND something interesting happens 6583 // (Don't log every single move - too noisy) 6584 if (draggedParent && draggedParent.classList.contains('nested-sortable') && draggedParent !== rootContainer) { 6585 const folderItems = Array.from(draggedParent.children); 6586 const draggedIndex = folderItems.indexOf(dragged); 6587 const relatedIndex = folderItems.indexOf(related); 6588 6589 // Get placeholder element to see where it actually is 6590 const placeholder = document.querySelector('.sortable-ghost'); 6591 const placeholderParent = placeholder ? placeholder.parentElement : null; 6592 const placeholderIndex = placeholderParent && placeholderParent.classList.contains('nested-sortable') 6593 ? Array.from(placeholderParent.children).indexOf(placeholder) 6594 : -1; 6595 6596 const folderItem = draggedParent.closest('li.has-children'); 6597 const folderItemId = folderItem ? folderItem.dataset.id : null; 6598 const firstItemInFolder = folderItems.length > 0 ? folderItems[0] : null; 6599 const firstItemInFolderId = firstItemInFolder ? firstItemInFolder.dataset.id : null; 6600 6601 // Check if we're trying to target first position 6602 // related could be the first item OR the folder item itself (when SortableJS uses folder as related) 6603 const isFirstPosition = folderItems.length > 0 && related === firstItemInFolder; 6604 const isRelatedFolderItemCheck = related === folderItem; 6605 const isTargetingFirstViaFolderItem = isRelatedFolderItemCheck && !!folderItem; // FIXED: was assigning folderItem instead of boolean 6606 6607 // When targeting first via folder item, placeholder should be at index 0 6608 // But we're seeing it at index 1 - this is the issue! 6609 const shouldBeAtFirstPosition = isTargetingFirstViaFolderItem && placeholderIndex === 1 && draggedIndex > 0; 6610 6611 // Check if placeholder is outside the folder when it should be inside 6612 const placeholderIsOutside = placeholderParent === rootContainer || (placeholderParent && !placeholderParent.classList.contains('nested-sortable')); 6613 const shouldBeInside = draggedIndex >= 0; // We're dragging from inside 6614 6615 // Only log when there's a potential issue or interesting state change 6616 const placeholderMismatch = placeholderIndex !== relatedIndex && placeholderIndex >= 0 && relatedIndex >= 0; 6617 const jumpingOut = !isMovingWithinSameFolder && draggedIndex >= 0 && relatedIndex < 0; 6618 const jumpingOutToRoot = placeholderIsOutside && shouldBeInside; 6619 6620 // First position issue: trying to target first but: 6621 // - placeholder is outside OR 6622 // - placeholder is at index 1 instead of 0 (when targeting first via folder item) 6623 const firstPositionIssue = (isFirstPosition || isTargetingFirstViaFolderItem) && (jumpingOut || jumpingOutToRoot || placeholderIsOutside || shouldBeAtFirstPosition); 6624 6625 // ALWAYS log when targeting first position via folder item (this is the key case we're debugging) 6626 // OR when placeholder jumps out when it should stay in 6627 // OR when placeholder should be at first position but isn't 6628 const shouldLog = placeholderMismatch || jumpingOut || jumpingOutToRoot || firstPositionIssue || isTargetingFirstViaFolderItem || shouldBeAtFirstPosition; 6629 6630 if (shouldLog) { 6631 console.error('ð [Placeholder Position]', { 6632 draggedId, 6633 draggedIndex, 6634 relatedId, 6635 relatedIndex, 6636 placeholderIndex, 6637 relatedElementTag: related.tagName, 6638 isRelatedFolderItem: isRelatedFolderItemCheck, 6639 isTargetingFirstViaFolderItem, 6640 isMovingWithinSameFolder, 6641 placeholderMismatch, 6642 jumpingOut, 6643 jumpingOutToRoot, 6644 isFirstPosition, 6645 placeholderIsOutside, 6646 placeholderParentType: placeholderParent === rootContainer ? 'ROOT' : (placeholderParent && placeholderParent.classList.contains('nested-sortable') ? 'FOLDER' : 'OTHER'), 6647 folderItemId, 6648 firstItemInFolderId, 6649 firstPositionIssue, 6650 shouldBeAtFirstPosition, 6651 expectedPlaceholderIndex: isTargetingFirstViaFolderItem ? 0 : placeholderIndex 6652 }); 6653 } 6654 } 6655 6656 // CRITICAL: For moves within the same folder, skip ALL the complex boundary logic 6657 // This allows smooth reordering just like items not in folders 6658 if (isMovingWithinSameFolder) { 6659 // Simple debounce only - same as root level items 6660 const timeSinceLastMove = currentTime - lastMoveTime; 6661 // Only prevent very rapid oscillation to same element 6662 if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.5 && relatedId === lastMoveRelatedId) { 6663 return false;
6664 } 6665 // Allow all moves within same folder - update tracking and continue 6666 6667 // SPECIAL FIX: When targeting first position via folder item, fix placeholder position 6668 // SortableJS places it at index 1 when related is the folder item, but we want it at index 0 6669 // BUT: Only apply this fix when actually near/at first position to avoid being too aggressive 6670 if (isRelatedFolderItem && draggedParent && draggedParent.classList.contains('nested-sortable') && draggedParent !== rootContainer) { 6671 const folderItems = Array.from(draggedParent.children); 6672 const firstItem = folderItems.length > 0 ? folderItems[0] : null; 6673 6674 // Only apply fix if we're actually near first position (draggedIndex is small, indicating we're moving up) 6675 // This prevents the fix from being too aggressive when moving to other positions 6676 const isNearFirstPosition = draggedIndex <= 2; // Allow fix when within first 3 positions 6677 6678 if (firstItem && firstItem !== dragged && isNearFirstPosition) { 6679 // Fix immediately (synchronously) first - don't wait for next frame 6680 const placeholder = document.querySelector('.sortable-ghost'); 6681 if (placeholder && placeholder.parentElement === draggedParent) { 6682 const currentIndex = Array.from(draggedParent.children).indexOf(placeholder); 6683 // Only fix if placeholder is at index 1 when we're targeting first position 6684 // Don't force it if it's at index 0 or beyond index 2 (user might be moving to position 2 or 3) 6685 if (currentIndex === 1 && draggedIndex <= 1) { 6686 draggedParent.insertBefore(placeholder, firstItem); 6687 } 6688 } 6689 } else { 6690 // Not near first position - stop any active fix loop 6691 firstPositionFixLoopActive = false; 6692 } 6693 } else { 6694 // Not targeting first position anymore - reset flag 6695 firstPositionFixLoopActive = false; 6696 } 6697 6698 lastMoveTime = currentTime; 6699 lastMoveRelatedId = relatedId; 6700 // Skip all the boundary case logic below - just continue to allow the move 6701 // Fall through to update intendedPlacementContainer and return true 6702 } else { 6703 // Only apply complex boundary logic for moves BETWEEN folders or to/from root 6704 6705 // Hysteresis: Prevent rapid switching between inside/outside folder 6706 const targetFolderItems = isMovingInsideFolder ? Array.from(relatedParent.children) : []; 6707 const isFirstPositionInFolder = isMovingInsideFolder && 6708 targetFolderItems.length > 0 && 6709 related === targetFolderItems[0]; 6710 // Special case: Empty folder - allow easy dropping into it 6711 const isEmptyFolder = isMovingInsideFolder && targetFolderItems.length === 0; 6712 6713 if (currentDecision && lastContainerDecision && currentDecision !== lastContainerDecision) { 6714 const timeSinceContainerSwitch = currentTime - lastContainerDecisionTime; 6715 6716 // Make first position easier to target from outside 6717 if (isFirstPositionInFolder) { 6718 // Moving to first position from outside - use shorter debounce 6719 const effectiveDebounce = CONTAINER_SWITCH_DEBOUNCE_MS * 0.3; 6720 if (timeSinceContainerSwitch < effectiveDebounce) {
6721 // Only block if it's very rapid (oscillation) 6722 if (timeSinceContainerSwitch < MOVE_DEBOUNCE_MS) { 6723 return false; 6724 } 6725 } 6726 } else if (isEmptyFolder) { 6727 // Empty folder - allow easy dropping, minimal debounce 6728 // Only block very rapid oscillation 6729 if (timeSinceContainerSwitch < MOVE_DEBOUNCE_MS * 0.2) { 6730 return false; 6731 } 6732 } else { 6733 // Normal container switch - apply full hysteresis 6734 if (timeSinceContainerSwitch < CONTAINER_SWITCH_DEBOUNCE_MS) { 6735 return false; 6736 } 6737 } 6738 } 6739 } 6740 6741 // Special handling for boundary cases (last item in folder, or dragging into folder) 6742 // BUT: Skip all this logic if we're moving within the same folder (already handled above) 6743 if (!isMovingWithinSameFolder && draggedParent && draggedParent.classList.contains('nested-sortable') && draggedParent !== rootContainer) { 6744 const folderItem = draggedParent.closest('li.has-children'); 6745 const currentFolderItems = Array.from(draggedParent.children); 6746 const isFirstPositionInCurrentFolder = currentFolderItems.length > 0 && 6747 currentFolderItems[0] !== dragged && 6748 (related === currentFolderItems[0] || 6749 (isMovingInsideFolder && relatedParent === draggedParent && 6750 related === currentFolderItems[0])); 6751 6752 // Case 0: This case is now handled earlier for same-folder moves 6753 // Only handle cross-folder moves here 6754 6755 // Case 1: Dragging last item out of folder 6756 if (isLastItemInFolder && isMovingOutsideFolder) { 6757 const timeSinceLastMove = currentTime - lastMoveTime; 6758 6759 // BUT: If we're trying to move to first position, don't block it 6760 // Check if the related element is the folder itself or right after it 6761 if (folderItem) { 6762 const folderNextSibling = folderItem.nextElementSibling; 6763 // If we're moving to position right after folder, it might be trying to go to first position 6764 // Allow it if it's been a reasonable time 6765 if (folderNextSibling && related === folderNextSibling) { 6766 // This is ambiguous - could be first position or outside 6767 // Require more time but not as much as pure outside move 6768 if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 2) { 6769 if (relatedId === lastMoveRelatedId) { 6770 return false; 6771 } 6772 } 6773 } else { 6774 // Definitely moving outside - require longer debounce 6775 if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 2.5) { 6776 if (relatedId === lastMoveRelatedId) { 6777 return false; // Reject if oscillating 6778 } 6779 } 6780 } 6781 } else { 6782 // No folder item found - apply standard debounce 6783 if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 2.5) { 6784 if (relatedId === lastMoveRelatedId) { 6785 return false; 6786 } 6787 } 6788 } 6789 } 6790 6791 // Case 2: Dragging item into folder from outside 6792 if (isMovingInsideFolder && relatedParent !== draggedParent) { 6793 const targetFolderItems = Array.from(relatedParent.children); 6794 const isEmptyFolder = targetFolderItems.length === 0; 6795 const isNearBottom = targetFolderItems.length > 0 && 6796 (related === targetFolderItems[targetFolderItems.length - 1] || 6797 targetFolderItems.indexOf(related) >= targetFolderItems.length - 1); 6798 const isFirstPos = targetFolderItems.length > 0 && related === targetFolderItems[0]; 6799 6800 // Special handling for empty folder - allow easy dropping 6801 if (isEmptyFolder) { 6802 const timeSinceLastMove = currentTime - lastMoveTime; 6803 // Very minimal debounce for empty folder - only prevent rapid oscillation 6804 if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.2 && relatedId === lastMoveRelatedId) { 6805 return false;
6806 } 6807 // Allow move into empty folder - don't block it 6808 } else if (isFirstPos) { 6809 // Moving to first position - make it easy 6810 const timeSinceLastMove = currentTime - lastMoveTime; 6811 // Very short debounce for first position 6812 if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.3 && relatedId === lastMoveRelatedId) { 6813 return false; 6814 } 6815 // Allow move to first position 6816 } else if (isNearBottom) { 6817 // For bottom position, still use some debounce to prevent oscillation 6818 const timeSinceLastMove = currentTime - lastMoveTime; 6819 if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 1.5) { 6820 if (relatedId === lastMoveRelatedId) { 6821 return false; 6822 } 6823 } 6824 } 6825 } 6826 6827 // Case 2.5: Prevent jumping OUT of folder when trying to move to first/last position within 6828 // This is tricky - SortableJS might detect it as outside when we're actually targeting first/last 6829 if (isMovingOutsideFolder && folderItem && draggedParent === draggedItemOriginalContainer) { 6830 // We're dragging from within a folder but detected as moving outside 6831 // Check if the related element is adjacent to the folder (which might indicate targeting first/last) 6832 const folderIndex = Array.from(rootContainer.children).indexOf(folderItem); 6833 const relatedIndex = Array.from(rootContainer.children).indexOf(related); 6834 6835 // If related is immediately before or after folder, might be targeting first/last 6836 if (folderIndex >= 0 && relatedIndex >= 0) { 6837 const isAdjacentToFolder = (relatedIndex === folderIndex - 1) || (relatedIndex === folderIndex + 1); 6838 6839 if (isAdjacentToFolder) { 6840 // Might be targeting first/last position - be lenient 6841 const timeSinceLastMove = currentTime - lastMoveTime;
6842 // Only block if it's very rapid oscillation 6843 if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.4 && relatedId === lastMoveRelatedId) { 6844 return false; 6845 } 6846 // Otherwise allow - user might be trying to target first/last position 6847 // Don't block based on this check 6848 } 6849 } 6850 } 6851 } 6852 6853 // Case 3: Dragging from root into folder 6854 if (draggedParent === rootContainer && isMovingInsideFolder) { 6855 const targetFolderItems = Array.from(relatedParent.children); 6856 const isEmptyFolder = targetFolderItems.length === 0; 6857 const isNearBottom = targetFolderItems.length > 0 && 6858 (related === targetFolderItems[targetFolderItems.length - 1] || 6859 targetFolderItems.indexOf(related) >= targetFolderItems.length - 1); 6860 const isFirstPosition = targetFolderItems.length > 0 && related === targetFolderItems[0]; 6861 6862 // Special handling for empty folder - allow easy dropping 6863 if (isEmptyFolder) { 6864 const timeSinceLastMove = currentTime - lastMoveTime; 6865 // Very minimal debounce for empty folder - only prevent rapid oscillation 6866 if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.2) { 6867 if (relatedId === lastMoveRelatedId) { 6868 return false; 6869 } 6870 } 6871 // Allow move into empty folder - don't block it 6872 } else if (isFirstPosition) { 6873 // Special handling for first position - make it easier to target 6874 const timeSinceLastMove = currentTime - lastMoveTime; 6875 // Make first position easier - only prevent very rapid oscillation 6876 if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.5) { 6877 if (relatedId === lastMoveRelatedId) { 6878 return false; 6879 } 6880 } 6881 // Allow move to first position - don't block it 6882 // Continue to track but don't apply strict debouncing 6883 } else if (isNearBottom) { 6884 // Keep strict debouncing for bottom position 6885 const timeSinceLastMove = currentTime - lastMoveTime; 6886 if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 2) { 6887 if (relatedId === lastMoveRelatedId) { 6888 return false; 6889 } 6890 } 6891 } 6892 } 6893 6894 // Case 4: Prevent jumping FROM first position to outside folder (but allow easy access TO first position) 6895 // If we're at first position and trying to move outside, require deliberate movement 6896 if (isMovingInsideFolder) { 6897 const currentFolderItems = Array.from(relatedParent.children); 6898 const isFirstPos = currentFolderItems.length > 0 && related === currentFolderItems[0]; 6899 6900 // If we're moving FROM first position to outside (reverse hysteresis) 6901 if (isFirstPos && lastContainerDecision === 'outside') { 6902 const timeSinceLastMove = currentTime - lastMoveTime; 6903 // If we just moved outside, require more time before allowing back to first position 6904 // BUT: if it's been a reasonable time, allow it (don't make it too hard) 6905 if (timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.8 && relatedId === lastMoveRelatedId) { 6906 // Very rapid - might be oscillation 6907 return false; 6908 } 6909 } 6910 } 6911 6912 // Case 5: Prevent oscillation between first and second position within folder 6913 // BUT: Only apply if NOT moving within same folder (to allow smooth reordering) 6914 if (isMovingInsideFolder && relatedParent && !isMovingWithinSameFolder) { 6915 const folderItems = Array.from(relatedParent.children); 6916 const isFirstPos = folderItems.length > 0 && related === folderItems[0]; 6917 const isSecondPos = folderItems.length > 1 && related === folderItems[1]; 6918 6919 // If moving from first to second (or vice versa) very rapidly, prevent oscillation 6920 if ((isFirstPos || isSecondPos) && lastMoveRelatedId) { 6921 const lastRelated = document.querySelector('#galleryTree li[data-id="' + lastMoveRelatedId + '"]') || document.querySelector('li[data-id="' + lastMoveRelatedId + '"]'); 6922 if (lastRelated && lastRelated.parentElement === relatedParent) { 6923 const lastRelatedIndex = folderItems.indexOf(lastRelated); 6924 const isOscillating = (isFirstPos && lastRelatedIndex === 1) || (isSecondPos && lastRelatedIndex === 0); 6925 6926 if (isOscillating) { 6927 const timeSinceLastMove = currentTime - lastMoveTime; 6928 // Allow moving to first position easily, but prevent rapid oscillation between first/second 6929 if (isSecondPos && timeSinceLastMove < MOVE_DEBOUNCE_MS * 1.2) { 6930 return false; // Prevent jumping from first to second too easily 6931 } 6932 // But allow first position more easily 6933 if (isFirstPos && timeSinceLastMove < MOVE_DEBOUNCE_MS * 0.6 && relatedId === lastMoveRelatedId) { 6934 return false; // Only prevent very rapid oscillation 6935 } 6936 } 6937 } 6938 } 6939 } 6940 6941 // Prevent rapid oscillation when dragging within same container 6942 // NOTE: For same-folder moves, we already updated tracking above, so skip this
6942section 6943 if (!isMovingWithinSameFolder) { 6944 const timeSinceLastMove = currentTime - lastMoveTime; 6945 6946 // If moving to the same related element very quickly, reject to prevent oscillation 6947 if (timeSinceLastMove < MOVE_DEBOUNCE_MS && relatedId === lastMoveRelatedId) { 6948 return false; 6949 } 6950 6951 // Update tracking variables (only for non-same-folder moves) 6952 lastMoveTime = currentTime; 6953 lastMoveRelatedId = relatedId; 6954 } 6955 // For same-folder moves, tracking was already updated in the earlier block 6956 6957 // Update container decision tracking 6958 if (currentDecision) { 6959 if (currentDecision !== lastContainerDecision) { 6960 lastContainerDecisionTime = currentTime; 6961 } 6962 lastContainerDecision = currentDecision; 6963 } 6964 6965 // Track where the placeholder indicates the item should be placed 6966 // The related element is next to where the placeholder will be positioned 6967 // Check if it's at root level or nested level 6968 if (related) { 6969 intendedRelatedElement = related; 6970 const relatedParent = related.parentElement; 6971 const rootContainer = document.querySelector('#galleryTree > ol.nested-sortable'); 6972 6973 // Determine if the placeholder is at root level or nested level 6974 if (relatedParent === rootContainer) { 6975 // Placeholder is at root level (not nested) 6976 intendedPlacementContainer = rootContainer; 6977 } else if (relatedParent && relatedParent.classList.contains('nested-sortable')) { 6978 // Placeholder is in a nested list (inside a folder) 6979 intendedPlacementContainer = relatedParent; 6980 } 6981 6982 // Update ghost element indentation to match the target position 6983 requestAnimationFrame(() => { 6984 const ghost = document.querySelector('.sortable-ghost'); 6985 if (ghost && intendedPlacementContainer) { 6986 const ghostContent = ghost.querySelector('.gallery-item-content'); 6987 if (ghostContent) { 6988 // Calculate the depth where the ghost will be placed 6989 // Use the related element to determine the depth (if it exists and is in the target container) 6990 let targetDepth = 0; 6991 if (intendedPlacementContainer === rootContainer) { 6992 targetDepth = 0; // Root level 6993 } else if (related && related.parentElement === intendedPlacementContainer) { 6994 // Use the related element's depth to determine target depth 6995 targetDepth = computeDepth(related); 6996 } else { 6997 // Fallback: find first child in the container or calculate from parent folder 6998 const firstChild = intendedPlacementContainer.querySelector('li'); 6999 if (firstChild) { 7000 targetDepth = computeDepth(firstChild); 7001 } else { 7002 // Empty container (like empty folder) - calculate from parent folder 7003 const parentFolder = intendedPlacementContainer.closest('li.has-children'); 7004 if (parentFolder) { 7005 targetDepth = computeDepth(parentFolder) + 1; 7006 } 7007 } 7008 } 7009 7010 // Special handling: If intendedPlacementContainer is empty and placeholder isn't in it yet, 7011 // ensure placeholder is moved there and indentation is correct 7012 if (intendedPlacementContainer.children.length === 0) { 7013 const placeholder = document.querySelector('.sortable-ghost'); 7014 if (placeholder && placeholder.parentElement !== intendedPlacementContainer) { 7015 // Move placeholder into empty container 7016 intendedPlacementContainer.appendChild(placeholder); 7017 } 7018 } 7019 ghostContent.style.paddingLeft = (targetDepth * 16) + 'px'; 7020 } 7021 } 7022 }); 7023 } 7024 7025 // Let SortableJS handle the placement based on placeholder position 7026 // We'll verify and correct if needed in onEnd 7027 return true; // Allow the move 7028 }, 7029 7030 // Capture indentation before drag starts (fires when item is chosen) 7031 onChoose: function(evt) { 7032 if (!evt.item) return; 7033 7034 const draggedItem = evt.item; 7035 const content = draggedItem.querySelector('.gallery-item-content'); 7036 7037 // Store the original padding-left for the ghost 7038 if (content) { 7039 const computedStyle = window.getComputedStyle(content); 7040 const originalPaddingLeft = computedStyle.paddingLeft; 7041 draggedItem.dataset.originalPaddingLeft = originalPaddingLeft; 7042 } 7043 }, 7044 7045 // Visual feedback on drag start 7046 onStart: function(evt) { 7047 if (!evt.item) return; 7048 7049 // Don't set dimensions on the dragged item - let it maintain natural size 7050 // Setting width/height can cause layout shifts and menu expansion 7051 const draggedItem = evt.item; 7052 7053 // Store original container and check if it's the last item in folder 7054 draggedItemOriginalContainer = draggedItem.parentElement; 7055 const rootContainer = document.querySelector('#galleryTree > ol.nested-sortable'); 7056 if (draggedItemOriginalContainer && 7057 draggedItemOriginalContainer.classList.contains('nested-sortable') && 7058 draggedItemOriginalContainer !== rootContainer) { 7059 const siblings = Array.from(draggedItemOriginalContainer.children); 7060 isLastItemInFolder = siblings.length > 0 && siblings[siblings.length - 1] === draggedItem; 7061 } else { 7062 isLastItemInFolder = false;
7063 } 7064 7065 // Apply the stored padding-left to the ghost element 7066 // The ghost is created by SortableJS at this point 7067 requestAnimationFrame(() => { 7068 const ghost = document.querySelector('.sortable-ghost'); 7069 if (ghost) { 7070 const ghostContent = ghost.querySelector('.gallery-item-content'); 7071 const originalPaddingLeft = draggedItem.dataset.originalPaddingLeft; 7072 if (ghostContent && originalPaddingLeft) { 7073 ghostContent.style.paddingLeft = originalPaddingLeft; 7074 } 7075 } 7076 }); 7077 7078 // Reset oscillation tracking variables 7079 lastMoveTime = 0; 7080 lastMoveRelatedId = null; 7081 lastContainerDecision = null; 7082 lastContainerDecisionTime = 0; 7083 7084 // Create a backup 7085 try { 7086 window._dragStartElementIds = []; 7087 document.querySelectorAll('#galleryTree .nested-sortable li').forEach(li => { 7088 if (li.dataset.id) { 7089 window._dragStartElementIds.push(li.dataset.id); 7090 } 7091 }); 7092 7093 galleriesBackup = JSON.parse(JSON.stringify(galleries)); 7094 } catch (error) { 7095 console.error('Error creating backup:', error); 7096 } 7097 7098 // Add indicator classes 7099 document.body.classList.add('menu-item-dragging'); 7100 7101 // Add hover indicator class to submenu items 7102 document.querySelectorAll('.has-children').forEach(item => { 7103 item.classList.add('potential-parent'); 7104 }); 7105 7106 // Prevent layout recalculation during drag 7107 document.body.style.setProperty('--is-dragging', '1'); 7108 }, 7109 7110 // Cleanup on end 7111 onEnd: function(evt) { 7112 // Verify and correct placement based on placeholder's intended position 7113 // SortableJS may auto-nest incorrectly based on mouse position, even if placeholder shows root level 7114 const dragged = evt.item; 7115 const previousParentLi = evt.from ? evt.from.closest('li') : null; 7116 const rootContainer = document.querySelector('#galleryTree > ol.nested-sortable'); 7117 7118 if (!dragged || !rootContainer) { 7119 intendedPlacementContainer = null; 7120 intendedRelatedElement = null; 7121 // Restore normal sizing 7122 dragged.style.width = ''; 7123 dragged.style.height = ''; 7124 dragged.style.minHeight = ''; 7125 document.body.style.removeProperty('--is-dragging'); 7126 return; 7127 } 7128 7129 // Get the actual parent container where SortableJS placed the item 7130 const actualParentList = dragged.parentElement; 7131 7132 // Restore normal sizing - use requestAnimationFrame to prevent layout thrashing 7133 requestAnimationFrame(() => { 7134 dragged.style.width = ''; 7135 dragged.style.height = ''; 7136 dragged.style.minHeight = ''; 7137 document.body.style.removeProperty('--is-dragging'); 7138 }); 7139 7140 if (intendedPlacementContainer && !document.body.contains(intendedPlacementContainer)) { 7141 intendedPlacementContainer = null; 7142 intendedRelatedElement = null; 7143 } 7144 7145 // Check if the intended placement (where placeholder showed) differs from actual placement 7146 // This happens when SortableJS auto-nests based on mouse position over a folder 7147 if (intendedPlacementContainer && actualParentList !== intendedPlacementContainer) { 7148 // The item ended up in a different container than the placeholder indicated 7149 // Move it to where the placeholder showed it should be (next to intendedRelatedElement) 7150 if (intendedPlacementContainer === rootContainer) { 7151 // Placeholder was at root level - move item to root, positioned next to related element 7152 if (intendedRelatedElement && intendedRelatedElement.parentElement === rootContainer) { 7153 // Insert before the related element (where placeholder was) 7154 rootContainer.insertBefore(dragged, intendedRelatedElement); 7155 console.log("Corrected placement: moved from folder to root level where placeholder indicated"); 7156 } else if (intendedRelatedElement) {
7157 // Related element exists but not at root - find its folder and insert before it at root 7158 const folderItem = intendedRelatedElement.closest('li.has-children'); 7159 if (folderItem && folderItem.parentElement === rootContainer) { 7160 rootContainer.insertBefore(dragged, folderItem); 7161 console.log("Corrected placement: moved to root level above folder where placeholder indicated"); 7162 } else { 7163 rootContainer.appendChild(dragged); 7164 } 7165 } else { 7166 rootContainer.appendChild(dragged); 7167 } 7168 // Update indentation since it's now at root 7169 applyIndentationFromDepth(dragged); 7170 } else { 7171 // Placeholder was in a nested list - move it there 7172 if (intendedRelatedElement && intendedRelatedElement.parentElement === intendedPlacementContainer) { 7173 // Insert before the related element in the nested list 7174 intendedPlacementContainer.insertBefore(dragged, intendedRelatedElement); 7175 } else { 7176 intendedPlacementContainer.appendChild(dragged); 7177 } 7178 console.log("Corrected placement: moved to nested list where placeholder indicated"); 7179 // Update indentation for nested item 7180 applyIndentationFromDepth(dragged); 7181 7182 // Ensure parent folder has proper structure 7183 const folderItem = intendedPlacementContainer.closest('li.has-children'); 7184 if (folderItem) { 7185 const nestedList = folderItem.querySelector('ol.nested-sortable'); 7186 if (nestedList && intendedPlacementContainer !== nestedList) { 7187 nestedList.appendChild(dragged); 7188 } 7189 7190 const parentDepth = computeDepth(folderItem); 7191 const nestedDepth = parentDepth + 1; 7192 7193 if (nestedList) { 7194 nestedList.className = 'nested-sortable nested-level-' + nestedDepth; 7195 } 7196 7197 folderItem.classList.add('has-children'); 7198 } 7199 } 7200 } else if (actualParentList && actualParentList.classList.contains('nested-sortable') && actualParentList !== rootContainer) { 7201 // Item is correctly in a nested list - ensure proper structure 7202 const folderItem = actualParentList.closest('li.has-children'); 7203 if (folderItem) { 7204 const nestedList = folderItem.querySelector('ol.nested-sortable'); 7205 if (nestedList && actualParentList !== nestedList) { 7206 nestedList.appendChild(dragged); 7207 } 7208 7209 const parentDepth = computeDepth(folderItem); 7210 const nestedDepth = parentDepth + 1; 7211 7212 if (nestedList) { 7213 nestedList.className = 'nested-sortable nested-level-' + nestedDepth; 7214 } 7215 7216 folderItem.classList.add('has-children'); 7217 } 7218 applyIndentationFromDepth(dragged); 7219 } else { 7220 // Item is correctly at root level 7221 applyIndentationFromDepth(dragged); 7222 } 7223 7224 // Reset tracking variables 7225 intendedPlacementContainer = null; 7226 intendedRelatedElement = null; 7227 isLastItemInFolder = false; 7228 draggedItemOriginalContainer = null; 7229 lastContainerDecision = null; 7230 lastContainerDecisionTime = 0; 7231 7232 // Apply indentation updates for ALL affected items after drag 7233 // Use requestAnimationFrame to batch DOM updates and prevent visual jumps 7234 requestAnimationFrame(() => { 7235 if (dragged) {
7236 // Update the dragged item and all its descendants 7237 applyIndentationFromDepth(dragged); 7238 7239 // Update all items in the new container (siblings of dragged item) 7240 const newContainer = dragged.parentElement; 7241 if (newContainer && newContainer.classList.contains('nested-sortable')) { 7242 const siblings = Array.from(newContainer.children); 7243 siblings.forEach(sibling => { 7244 if (sibling !== dragged && sibling.tagName === 'LI') { 7245 applyIndentationFromDepth(sibling); 7246 } 7247 }); 7248 } 7249 7250 // Update all items in the old container (if it still exists and has items) 7251 if (evt.from && evt.from.classList.contains('nested-sortable')) { 7252 const oldSiblings = Array.from(evt.from.children); 7253 oldSiblings.forEach(sibling => { 7254 if (sibling.tagName === 'LI') { 7255 applyIndentationFromDepth(sibling); 7256 } 7257 }); 7258 } 7259 7260 // Also update all items in the entire tree to ensure consistency 7261 // This handles edge cases where indentation might be off 7262 const allItems = document.querySelectorAll('#galleryTree .nested-sortable li'); 7263 allItems.forEach(li => { 7264 const depth = computeDepth(li); 7265 li.setAttribute('data-nesting-level', depth); 7266 const content = li.querySelector('.gallery-item-content'); 7267 if (content) { 7268 content.style.paddingLeft = (depth * 16) + 'px'; 7269 } 7270 }); 7271 } 7272 }); 7273 7274 // Clean up previous parent if it lost its last child 7275 if (previousParentLi) { 7276 cleanupParentIfEmpty(previousParentLi); 7277 } 7278 7279 // Remove all indicator classes 7280 document.body.classList.remove('menu-item-dragging'); 7281 document.querySelectorAll('.potential-parent, .will-accept-child, .will-be-nested').forEach(el => { 7282 el.classList.remove('potential-parent', 'will-accept-child', 'will-be-nested'); 7283 }); 7284 7285 // Standard cleanup for ghost elements 7286 document.querySelectorAll('.sortable-ghost, .sortable-chosen, .sortable-drag').forEach(item => { 7287 item.classList.remove('sortable-ghost', 'sortable-chosen', 'sortable-drag'); 7288 }); 7289 7290 // Check if drop was outside a valid container 7291 if (!evt.to || !evt.to.classList.contains('nested-sortable')) { 7292 console.warn('Item was dropped outside a valid container - reverting'); 7293 restoreFromBackup(); 7294 return; 7295 } 7296 7297 // Verify no elements were lost 7298 try { 7299 // Check DOM elements 7300 const endElementIds = []; 7301 document.querySelectorAll('#galleryTree .nested-sortable li').forEach(li => { 7302 if (li.dataset.id) { 7303 endElementIds.push(li.dataset.id); 7304 } 7305 }); 7306 7307 if (window._dragStartElementIds && window._dragStartElementIds.length > 0) { 7308 const startCount = window._dragStartElementIds.length; 7309 const endCount = endElementIds.length; 7310 7311 if (endCount < startCount) { 7312 console.error(`DOM elements were lost during drag! Original: ${startCount}, New: ${endCount}`); 7313 restoreFromBackup(); 7314 return; 7315 } 7316 } 7317 7318 // Update the data structure 7319 updateGalleryStructure(); 7320 7321 // Verify data model 7322 const originalCount = countGalleries(galleriesBackup); 7323 const newCount = countGalleries(galleries); 7324 7325 if (newCount < originalCount) { 7326 console.error(`Items were lost! Original: ${originalCount}, New: ${newCount}`); 7327 restoreFromBackup(); 7328 return; 7329 } 7330 7331 // updateGalleryStructure already persists; do not save or re-render again 7332 // here. A second save/render can rebind Sortable and snap the drop back. 7333 } catch (error) { 7334 console.error('Error updating gallery structure:', error); 7335 restoreFromBackup(); 7336 } 7337 } 7338 }); 7339 7340 sortableInstances.push(sortable); 7341 } catch (error) { 7342 console.error(`Error initializing sortable for container ${i}:`, error); 7343 } 7344 } 7345 7346 // Add event delegation for folder toggles (will be added by setupFolderToggleHandler) 7347 setupFolderToggleHandler(); 7348 7349 return sortableInstances; 7350} 7351 7352// Helper function to set up folder toggle event delegation 7353// This must be called after galleries are rendered, in both view and edit modes 7354function setupFolderToggleHandler() { 7355 const galleryTree = document.getElementById('galleryTree'); 7356 if (galleryTree && !galleryTree.dataset.folderToggleHandlerAdded) { 7357 galleryTree.addEventListener('click', function(e) { 7358 7359 const target = e.target; 7360 // Resolve the clicked row first â do not use target.closest('.menu-item.folder'), 7361 // which matches ancestor folder labels when clicking nested pages/galleries. 7362 const listItem = target.closest('li[data-id]'); 7363 if (!listItem) { 7364 return; 7365 } 7366 7367 const contentDiv = listItem.querySelector(':scope > .gallery-item-content'); 7368 if (!contentDiv) { 7369 return; 7370 } 7371 7372 const toggleButton = contentDiv.querySelector('.submenu-toggle'); 7373 const folderText = contentDiv.querySelector('.menu-item.folder'); 7374 const isToggleClick = toggleButton && toggleButton.contains(target); 7375 const isFolderTextClick = folderText && folderText.contains(target); 7376 7377 7378 if (isToggleClick || isFolderTextClick) { 7379 const folderId = parseInt(listItem.dataset.id, 10); 7380 7381 if (folderId && !isNaN(folderId)) { 7382 // Prevent SortableJS from intercepting this click 7383 e.stopPropagation(); 7384 e.stopImmediatePropagation(); 7385 toggleFolder(folderId, e); 7386 } 7387 } 7388 }, true); // Use capture phase to run before SortableJS 7389 7390 galleryTree.dataset.folderToggleHandlerAdded = 'true'; 7391 } 7392} 7393 7394// NEW HELPER FUNCTION: Gets the nesting level of an element 7395function getElementNestingLevel(element) { 7396 let level = 0; 7397 let current = element; 7398 7399 // Count how many nested-sortable parents this element has 7400 while (current && current.parentElement) { 7401 if (current.parentElement.classList && current.parentElement.classList.contains('nested-sortable')) { 7402 level++; 7403 } 7404 current = current.parentElement; 7405 } 7406 7407 return level; 7408} 7409 7410// NEW HELPER FUNCTION: Determines if an item would become nested 7411function wouldBeNestedItem(draggedEl, relatedEl, event) { 7412 if (!relatedEl || !draggedEl) return false;
7413 7414 // Get mouse position 7415 const mouseX = event.clientX; 7416 7417 // Get the bounding rect of the related element 7418 const rect = relatedEl.getBoundingClientRect(); 7419 7420 // Determine the nesting threshold - how far from the left edge 7421 // the mouse needs to be to consider it a nesting operation 7422 const nestingThreshold = 25; // pixels from left edge 7423 7424 // Check if mouse is within the nesting threshold from the left edge 7425 const distanceFromLeft = mouseX - rect.left; 7426 7427 // Check if we should nest (mouse is within threshold of left edge) 7428 const wouldNest = distanceFromLeft <= nestingThreshold; 7429 7430 // Log the calculation for debugging 7431 console.log(`Nesting check: distance=${distanceFromLeft}px, threshold=${nestingThreshold}px, would nest=${wouldNest}`); 7432 7433 return wouldNest; 7434} 7435 7436/** 7437 * Helper function to find gallery by ID in a tree structure 7438 * @param {Array} galleries - The gallery array to search 7439 * @param {number} id - The ID to find 7440 * @returns {Object|null} - The found gallery or null 7441 */ 7442function findGalleryById(galleries, id) { 7443 if (window.HelpSystem && typeof window.HelpSystem.isDemoId === 'function' && window.HelpSystem.isDemoId(id)) { 7444 return window.HelpSystem.getDemoRecord(id); 7445 } 7446 if (!Array.isArray(galleries)) return null; 7447 7448 // First check at the current level 7449 const directMatch = galleries.find(g => g.id == id); 7450 if (directMatch) return directMatch; 7451 7452 // Then check in children 7453 for (const gallery of galleries) { 7454 if (gallery.children && gallery.children.length > 0) { 7455 const childMatch = findGalleryById(gallery.children, id); 7456 if (childMatch) return childMatch; 7457 } 7458 } 7459 7460 return null; 7461} 7462 7463/** Detect page menu items even when isPage flag was not persisted */ 7464function isPageMenuItem(item) { 7465 if (!item) return false; 7466 if (item.isPage === true) return true; 7467 if (item.pageId && String(item.pageId).indexOf('page_') === 0) return true; 7468 if (Array.isArray(item.pageElements) && item.pageElements.length > 0) return true; 7469 return false; 7470} 7471 7472/** 7473 * Return true only for pages/galleries that explicitly opted into navigable-parent behavior. 7474 * Never infer this from children or parentId; legacy items must retain their existing behavior. 7475 */ 7476function isNavigableMenuParent(item) { 7477 if (!item || item.actsAsMenuParent !== true) return false; 7478 if (item.isFolder === true || item.isSubmenu === true || item.isSpacer === true || item.isExternal === true) { 7479 return false; 7480 } 7481 return isPageMenuItem(item) || item.isIntegrated === true; 7482} 7483 7484/** 7485 * Get all gallery IDs from a tree structure 7486 * @param {Array} galleries - Array of galleries 7487 * @returns {Array} - Flat array of all IDs 7488 */ 7489function getAllGalleryIds(galleries) { 7490 if (!Array.isArray(galleries)) return []; 7491 7492 let ids = []; 7493 7494 galleries.forEach(gallery => { 7495 if (gallery && gallery.id) { 7496 ids.push(gallery.id); 7497 } 7498 7499 // Add child IDs if any 7500 if (gallery.children && Array.isArray(gallery.children)) { 7501 ids = ids.concat(getAllGalleryIds(gallery.children)); 7502 } 7503 }); 7504 7505 return ids; 7506} 7507 7508/** 7509 * Enhanced count function that handles edge cases 7510 * @param {Array} galleryArray - The array to count 7511 * @returns {number} - The total count of galleries 7512 */ 7513function countGalleries(galleryArray) { 7514 if (!Array.isArray(galleryArray)) return 0; 7515 7516 // Menu items are stored flat and also get a derived children[] at render time. 7517 // Counting children recursively double-counts those items and falsely triggers 7518 // restoreFromBackup after a successful drop. 7519 const ids = new Set(); 7520 function walk(items) { 7521 if (!Array.isArray(items)) return; 7522 items.forEach(function(gallery) { 7523 if (!gallery) return; 7524 if (gallery.id != null) ids.add(gallery.id); 7525 if (gallery.children && Array.isArray(gallery.children)) walk(gallery.children); 7526 }); 7527 } 7528 walk(galleryArray); 7529 return ids.size; 7530} 7531 7532/** 7533 * Enhanced backup restoration function with better debugging and notification 7534 */ 7535function restoreFromBackup() { 7536 console.warn('Restoring galleries from backup due to invalid operation'); 7537 7538 // Verify we have a valid backup 7539 if (!galleriesBackup || !Array.isArray(galleriesBackup) || galleriesBackup.length === 0) { 7540 console.error('No valid backup available for restoration'); 7541 7542 // Create a backup from the current DOM as a last resort 7543 try { 7544 console.log('Attempting to rebuild from DOM structure...'); 7545 rebuildGalleriesFromDOM(); 7546 return; 7547 } catch (rebuildError) { 7548 console.error('Failed to rebuild from DOM:', rebuildError); 7549 // Continue with normal user notification 7550 } 7551 } else { 7552 // Restore from backup 7553 galleries = JSON.parse(JSON.stringify(galleriesBackup)); 7554 } 7555 7556 // Re-render the tree with the restored data 7557 renderGalleries(); 7558 7559 // Force reinitialize nested sortables after restoration 7560 setTimeout(() => { 7561 initializeNestedSortables(); 7562 }, 300); 7563} 7564 7565/** 7566 * Emergency recovery function that tries to rebuild galleries from DOM 7567 * This is a last resort when backup restoration fails 7568 */ 7569function rebuildGalleriesFromDOM() { 7570 console.log('EMERGENCY RECOVERY: Attempting to rebuild galleries from DOM structure'); 7571 7572 // Create a new galleries array 7573 const rebuiltGalleries = []; 7574 7575 // This will track items we've already processed 7576 const processedIds = new Set(); 7577 7578 // Function to process a container and its children 7579 function processContainer(container, parentId = null) { 7580 // Get all immediate list items 7581 const items = container.querySelectorAll(':scope > li'); 7582
7583 items.forEach(item => { 7584 // Get item ID from data attribute 7585 const id = parseInt(item.dataset.id); 7586 if (isNaN(id)) return; // Skip if no valid ID 7587 7588 // Skip already processed items to avoid duplicates 7589 if (processedIds.has(id)) return; 7590 processedIds.add(id); 7591 7592 // Try to find the original gallery data 7593 let galleryData = null; 7594 7595 // First check current galleries array 7596 const existingGallery = findGalleryById(galleries, id); 7597 if (existingGallery) { 7598 galleryData = { ...existingGallery }; 7599 delete galleryData.children; // We'll rebuild the hierarchy 7600 } 7601 // If not found, check if we have a backup 7602 else if (galleriesBackup && Array.isArray(galleriesBackup)) { 7603 const backupGallery = findGalleryById(galleriesBackup, id); 7604 if (backupGallery) { 7605 galleryData = { ...backupGallery }; 7606 delete galleryData.children; // We'll rebuild the hierarchy 7607 } 7608 } 7609 7610 // If we still don't have data, create minimal placeholder 7611 if (!galleryData) { 7612 // Try to extract title from DOM 7613 const titleElement = item.querySelector('.menu-item'); 7614 const title = titleElement ? titleElement.textContent.trim() : `Item ${id}`; 7615 7616 galleryData = { 7617 id: id, 7618 title: title, 7619 visible: !item.classList.contains('hidden-gallery') 7620 }; 7621 } 7622 7623 // Update parent ID to match DOM structure 7624 galleryData.parentId = parentId; 7625 7626 // Add to rebuilt galleries 7627 rebuiltGalleries.push(galleryData); 7628 7629 // Process children if any 7630 const childContainer = item.querySelector('ol.nested-sortable'); 7631 if (childContainer) { 7632 processContainer(childContainer, id); 7633 } 7634 }); 7635 } 7636 7637 // Start with root container 7638 const rootContainer = document.querySelector('#galleryTree > ol.nested-sortable'); 7639 if (rootContainer) { 7640 processContainer(rootContainer); 7641 7642 // Replace global galleries array with our rebuilt one 7643 galleries = rebuiltGalleries; 7644 7645 // Re-render with the rebuilt data 7646 renderGalleries(); 7647 7648 console.log(`RECOVERY COMPLETE: Rebuilt ${rebuiltGalleries.length} items from DOM`); 7649 return true; 7650 } else { 7651 console.error('RECOVERY FAILED: Root container not found'); 7652 return false; 7653 } 7654} 7655 7656// Make sure these functions are available to the global scope 7657window.initializeNestedSortables = initializeNestedSortables; 7658window.restoreFromBackup = restoreFromBackup; 7659window.getElementNestingLevel = getElementNestingLevel; 7660window.wouldBeNestedItem = wouldBeNestedItem; 7661 7662 7663 7664function hasDescendant(itemId, possibleDescendantId) { 7665// Check if possibleDescendantId is a descendant of itemId 7666const children = galleries.filter(g => g.parentId === itemId); 7667 7668if (children.some(child => child.id === possibleDescendantId)) { 7669return true; 7670} 7671 7672return children.some(child => hasDescendant(child.id, possibleDescendantId)); 7673} 7674 7675function updateGalleryStructure() { 7676// Create a new array to hold the updated gallery structure 7677const newGalleries = []; 7678 7679// Function to recursively process nested lists 7680function processNestedList(container, parentId = null) { 7681if (!container || !container.children) { 7682 console.warn('Invalid container in processNestedList', container); 7683 return; 7684} 7685 7686const items = container.children; 7687 7688Array.from(items).forEach(item => { 7689 if (item.tagName !== 'LI') return; // Skip non-list items 7690 7691 const id = parseInt(item.dataset.id); 7692 if (isNaN(id)) { 7693 console.warn('Invalid ID in list item:', item); 7694 return; 7695 } 7696 7697 const gallery = galleries.find(g => g.id === id); 7698 7699 if (gallery) { 7700 // Create a new gallery object without children 7701 const newGallery = { ...gallery }; 7702 if (newGallery.children) delete newGallery.children; 7703 7704 // Update parent ID 7705 newGallery.parentId = parentId; 7706 7707 // Add to new galleries array 7708 newGalleries.push(newGallery); 7709 7710 // Process any nested OL with class 'nested-sortable' within this item 7711 const nestedList = item.querySelector('ol.nested-sortable'); 7712 if (nestedList) { 7713 processNestedList(nestedList, id); 7714 } 7715 } else { 7716 console.warn(`Gallery with ID ${id} not found in galleries array`); 7717 } 7718}); 7719} 7720 7721// Start processing from the root list 7722const rootList = document.querySelector('#galleryTree > ol.nested-sortable'); 7723if (rootList) { 7724try { 7725 processNestedList(rootList); 7726 7727 // Verify that we haven't lost any galleries 7728 if (newGalleries.length < galleries.length) { 7729 console.error(`Gallery count mismatch! Original: ${galleries.length}, New: ${newGalleries.length}`); 7730 7731 // Find missing galleries 7732 const originalIds = galleries.map(g => g.id); 7733 const newIds = newGalleries.map(g => g.id); 7734 const missingIds = originalIds.filter(id => !newIds.includes(id)); 7735 7736 if (missingIds.length > 0) { 7737 console.error('Missing gallery IDs:', missingIds); 7738 7739 // Add missing galleries to the new structure 7740 missingIds.forEach(id => { 7741 const missingGallery = galleries.find(g => g.id === id); 7742 if (missingGallery) { 7743 // Add it as a top-level item 7744 const newGallery = { ...missingGallery, parentId: null }; 7745 if (newGallery.children) delete newGallery.children; 7746 newGalleries.push(newGallery); 7747 console.log(`Rescued gallery: ${newGallery.title} (ID: ${newGallery.id})`); 7748 } 7749 }); 7750 } 7751 } 7752 7753 // Update the galleries array with the new structure 7754 galleries = newGalleries; 7755 if (typeof window !== 'undefined') { 7756 window.galleries = galleries; 7757 } 7758 7759 // Save the updated structure 7760 saveGalleries(); 7761} catch (error) { 7762 console.error('Error in updateGalleryStructure:', error); 7763 throw error; // Re-throw to trigger the backup restore 7764} 7765} else { 7766console.warn('Root list not found'); 7767throw new Error('Root list not found'); // Throw error to trigger backup restore 7768} 7769} 7770 7771function toggleSubmenu(id, event) { 7772// Ensure the event doesn't interfere with drag operations 7773event.stopPropagation(); 7774 7775const listItem = getGalleryTreeListItem(id, event); 7776if (listItem) { 7777// Toggle the expanded class 7778listItem.classList.toggle('expanded'); 7779 7780// Find the submenu toggle icon and rotate it 7781const toggleIcon = listItem.querySelector('.toggle-icon'); 7782if (toggleIcon) { 7783 toggleIcon.classList.toggle('rotated'); 7784} 7785} 7786} 7787 7788/** 7789 * Improved slugify function with robust character handling 7790 * @param {string} text - The text to convert to a slug 7791 * @returns {string} A URL-friendly slug 7792 */ 7793function slugify(text) { 7794 // Guard against null or empty input 7795 if (!text || text.trim() === '') { 7796 return 'page-' + Date.now(); 7797 } 7798 7799 // First convert to lowercase 7800 let slug = text.toLowerCase(); 7801 7802 // Replace spaces with hyphens 7803 slug = slug.split(' ').join('-'); 7804 7805 // Filter out unwanted characters (keep only a-z, 0-9, and hyphens) 7806 let filtered = ''; 7807 for (let i = 0; i < slug.length; i++) { 7808 const char = slug.charAt(i); 7809 if ((char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char === '-') { 7810 filtered += char; 7811 } 7812 } 7813 slug = filtered; 7814 7815 /* 7816 // Clean up multiple hyphens 7817 while (slug.indexOf('--') !== -1) { 7818 slug = slug.replace('--', '-'); 7819 } 7820 */ 7821 7822 // Remove leading and trailing hyphens 7823 if (slug.startsWith('-')) { 7824 slug = slug.substring(1); 7825 } 7826 if (slug.endsWith('-')) { 7827 slug = slug.substring(0, slug.length - 1); 7828 } 7829 7830 // If slug is empty after processing, use a default 7831 if (!slug || slug === '-') { 7832 return 'page-' + Date.now(); 7833 } 7834 7835 return slug; 7836} 7837 7838// Update the addPage function to use our new robust slugify function 7839/**
7840 * Adds a new page to the galleries array 7841 * @returns {number|null} The new page ID or null if error 7842 */ 7843function addPage() { 7844 try { 7845 // Get the page title 7846 const titleInput = document.getElementById('galleryTitle'); 7847 if (!titleInput) { 7848 console.error('Page title input not found'); 7849 return; 7850 } 7851 const title = titleInput.value.trim(); 7852 7853 // Validate title is not empty 7854 if (!title) { 7855 alert('Please enter a page title'); 7856 return; 7857 } 7858 7859 // Get parent ID with error handling 7860 const parentSelect = document.getElementById('galleryParent'); 7861 const parentId = parentSelect && parentSelect.value ? parseInt(parentSelect.value) : null; 7862 7863 // Generate a unique ID for the page 7864 const id = Date.now(); 7865 const pageId = `page_${id}`; 7866 7867 // Calculate slug using our more robust slugify function 7868 const pageSlug = slugify(title); 7869 7870 // Create the page object with explicit properties 7871 const page = { 7872 id: id, 7873 title: title, 7874 isPage: true, 7875 isIntegrated: true, 7876 isSubmenu: false, 7877 pageId: pageId, 7878 visible: true, 7879 parentId: parentId, 7880 siteId: siteId, 7881 // Set slug and URL explicitly 7882 slug: pageSlug, 7883 url: '/' + pageSlug 7884 }; 7885 7886 console.log(`Creating new page with ID: ${id}, title: "${title}"`); 7887 7888 // Add to galleries array 7889 galleries.push(page); 7890 7891 // CRITICAL: Ensure window.galleries is synchronized 7892 if (typeof window.galleries === 'undefined') { 7893 window.galleries = galleries; 7894 } else { 7895 // Make sure the page is in window.galleries too 7896 const existingIndex = window.galleries.findIndex(g => g.id === id); 7897 if (existingIndex >= 0) { 7898 window.galleries[existingIndex] = page; 7899 } else { 7900 window.galleries.push(page); 7901 } 7902 } 7903 7904 // Initialize empty page elements array in PageManager 7905 if (window.PageManager) { 7906 window.PageManager.elements[pageId] = []; 7907 } 7908 7909 // Update UI first 7910 renderGalleries(); 7911 clearAddForm(); 7912 toggleAddForm(); 7913 7914 // Set as active page 7915 console.log(`Setting new page "${page.title}" as active`); 7916 activeGalleryId = id; 7917 window.activeGalleryId = id; 7918 7919 // Update active states in the menu 7920 updateActiveStates(); 7921 7922 // Save to server 7923 saveGalleries().then(() => { 7924 console.log(`Page "${page.title}" saved successfully`); 7925 7926 // Double check that our gallery is still in the array 7927 const pageExists = galleries.some(g => g.id === id); 7928 console.log(`Page exists in galleries array: ${pageExists}`); 7929 7930 if (pageExists) { 7931 // Load the new page after a slight delay to ensure DOM is updated 7932 setTimeout(() => { 7933 console.log(`Loading new page "${page.title}" with ID: ${id}`); 7934 loadPage(id); 7935 }, 50); 7936 } else { 7937 console.error(`Page with ID ${id} not found in galleries array after save`); 7938 } 7939 }).catch(error => { 7940 alert('Error saving page. Please try again.'); 7941 }); 7942 7943 return id; 7944 } catch (error) { 7945 console.error('Error adding page:', error); 7946 alert('Error adding page. Please try again.'); 7947 return null; 7948 } 7949} 7950 7951/** 7952 * Ensures a slug is unique among existing galleries 7953 * @param {string} slug - The base slug to check 7954 * @param {Array} existingGalleries - Array of existing gallery configs 7955 * @returns {string} A unique slug 7956 */ 7957function ensureUniqueSlug(slug, existingGalleries) { 7958 // Guard against invalid slugs 7959 if (!slug || slug === '-') { 7960 slug = 'page-' + Date.now(); 7961 } 7962 7963 let finalSlug = slug; 7964 let counter = 1; 7965 7966 // Check if slug already exists 7967 while (existingGalleries.some(g => g.slug === finalSlug)) { 7968 finalSlug = `${slug}-${counter}`; 7969 counter++; 7970 } 7971 7972 return finalSlug; 7973} 7974 7975/** 7976 * Adds a new gallery based on the selected type 7977 * @returns {number|null} The new gallery ID or null if error 7978 */ 7979function addGallery() { 7980 try { 7981 // Get the gallery title 7982 const titleInput = document.getElementById('galleryTitle'); 7983 if (!titleInput) { 7984 console.error('Gallery title input not found'); 7985 return; 7986 } 7987 const title = titleInput.value.trim(); 7988 7989 // Get the selected gallery type 7990 const galleryTypeRadio = document.querySelector('input[name="galleryType"]:checked'); 7991 if (!galleryTypeRadio) { 7992 console.error('No gallery type selected'); 7993 alert('Please select a gallery type'); 7994 return; 7995 } 7996 7997 const galleryType = galleryTypeRadio.value; 7998 console.log("Selected gallery type:", galleryType); 7999 8000 // If this is a page, use the addPage function 8001 if (galleryType === 'page') { 8002 console.log("Creating page instead of gallery"); 8003 return addPage(); 8004 } 8005 8006 // For spacer type, we don't require a title 8007 if (galleryType !== 'spacer' && !title) { 8008 alert('Please enter a gallery title'); 8009 return; 8010 } 8011 8012 // Continue with gallery creation logic 8013 // Generate unique ID 8014 const id = Date.now(); 8015 8016 // Get parent gallery if selected 8017 const parentSelect = document.getElementById('galleryParent'); 8018 const parentId = parentSelect.value ? parseInt(parentSelect.value) : null; 8019 8020 // Get URL if this is an external gallery 8021 const galleryUrl = document.getElementById('galleryUrl'); 8022 const url = galleryTypeRadio.value === 'external' && galleryUrl 8023 ? galleryUrl.value.trim() 8024 : ""; 8025 8026 // Set basic properties 8027 const gallery = { 8028 id: id, 8029 title: title || "Spacer", // Use "Spacer" as internal title if no title provided 8030 parentId: parentId, 8031 visible: true 8032 }; 8033 8034 // Set type-specific properties 8035 if (galleryTypeRadio.value === 'spacer') { 8036 // Spacer properties 8037 gallery.isSpacer = true; 8038 gallery.title = title || "Spacer"; // For internal reference 8039 } else if (galleryTypeRadio.value === 'integrated') { 8040 // Generate pageId for integrated galleries 8041 gallery.isIntegrated = true; 8042 gallery.pageId = generatePageId(); 8043 8044 // Generate and ensure unique slug 8045 const baseSlug = slugify(title); 8046 gallery.slug = ensureUniqueSlug(baseSlug, galleries); 8047 gallery.url = "/" + gallery.slug;
8048 8049 // Get the siteId from multiple possible sources 8050 const siteId = window.Parameters?.siteId || 8051 window.siteId || 8052 document.querySelector('meta[name="hydra-site-id"]')?.getAttribute('content') || 8053 ''; 8054 8055 // Add galleryOptions with GUID=siteId as the manualCollectionName 8056 gallery.galleryOptions = { 8057 manualCollectionName: `GUID=${siteId}`, 8058 isIntegratedGallery: true, 8059 pageId: gallery.pageId, // Store pageId for reference 8060 siteId: siteId 8061 }; 8062 } else if (galleryTypeRadio.value === 'folder') { 8063 gallery.isFolder = true; 8064 gallery.isCollapsed = true; // Make folders closed by default 8065 } else if (galleryTypeRadio.value === 'external') { 8066 // External URL 8067 gallery.url = url; 8068 gallery.isExternal = true; // Add this flag to identify external URLs 8069 } 8070 8071 console.log(`Creating new gallery with ID: ${id}, title: "${title}"`); 8072 8073 // Add to galleries array 8074 galleries.push(gallery); 8075 8076 // CRITICAL: Ensure window.galleries is synchronized 8077 if (typeof window.galleries === 'undefined') { 8078 window.galleries = galleries; 8079 } else { 8080 // Make sure the gallery is in window.galleries too 8081 const existingIndex = window.galleries.findIndex(g => g.id === id); 8082 if (existingIndex >= 0) { 8083 window.galleries[existingIndex] = gallery; 8084 } else { 8085 window.galleries.push(gallery); 8086 } 8087 } 8088 8089 // Update UI first 8090 renderGalleries(); 8091 clearAddForm(); 8092 toggleAddForm(); 8093 8094 // Save galleries after UI update 8095 saveGalleries().then(() => { 8096 console.log(`Gallery "${gallery.title}" saved successfully`); 8097 8098 // Double check that our gallery is still in the array 8099 const galleryExists = galleries.some(g => g.id === id); 8100 console.log(`Gallery exists in galleries array: ${galleryExists}`); 8101 8102 // For non-folder types, load the new gallery content after a slight delay 8103 if (!gallery.isFolder && !gallery.isSubmenu && !gallery.isSpacer && gallery.visible !== false) { 8104 setTimeout(() => { 8105 console.log(`Loading new gallery "${gallery.title}" with ID: ${id}`); 8106 if (gallery.isIntegrated) { 8107 loadGallery(id); 8108 } else if (gallery.isExternal && gallery.url) { 8109 // For external URLs, set the iframe src and mark active after navigation target is ready 8110 activeGalleryId = id; 8111 window.activeGalleryId = id; 8112 updateActiveStates(); 8113 const frame = document.getElementById('galleryFrame'); 8114 if (frame) frame.src = gallery.url; 8115 } 8116 }, 50); 8117 } 8118 }).catch(error => { 8119 console.error(`Error saving gallery "${gallery.title}":`, error); 8120 alert('Error saving gallery. Please try again.'); 8121 }); 8122 8123 // Return the new gallery ID 8124 return id; 8125 } catch (error) { 8126 console.error('Error adding gallery:', error); 8127 alert('Error adding gallery. Please try again.'); 8128 } 8129} 8130 8131function debugDOMState(message) { 8132console.log(`[DEBUG] ${message}`); 8133const galleryContainer = document.querySelector('.gallery-container'); 8134const iframe = document.getElementById('galleryFrame'); 8135const pageContainers = document.querySelectorAll('.page-container'); 8136 8137console.log(`- Gallery container: ${galleryContainer ? 'found' : 'not found'}`); 8138console.log(`- iframe: ${iframe ? 'found' : 'not found'}, display: ${iframe?.style.display}`); 8139console.log(`- Page containers: ${pageContainers.length} found`); 8140 8141pageContainers.forEach((container, index) => { 8142console.log(` - Container ${index}: display: ${container.style.display}, visibility: ${container.style.visibility}`); 8143}); 8144} 8145 8146 8147/** 8148* Global helper: whether edit tools are explicitly toggled open. 8149* Do not use Parameters.isInEditor â that is also true for logged-in admins (cache bypass). 8150* Do not infer from sidebar/page .editing alone (can desync from the toggle). 8151*/ 8152window.isInEditMode = function() { 8153 if (document.body.classList.contains('edit-mode-active')) { 8154 return true; 8155 } 8156 if (typeof window.isEditing === 'boolean') { 8157 return window.isEditing; 8158 } 8159 return false;
8160}; 8161 8162/** 8163* This function synchronizes edit mode state across all components 8164*/ 8165window.updateGlobalEditState = function(isEditing) { 8166console.log('Updating global edit state:', isEditing); 8167 8168// Update global variable 8169window.isEditing = isEditing; 8170 8171// Update Parameters object if it exists 8172if (window.Parameters) { 8173window.Parameters.isInEditor = isEditing; 8174} 8175 8176// Update sidebar class 8177const sidebar = document.querySelector('.sidebar'); 8178if (sidebar) { 8179if (isEditing) { 8180 sidebar.classList.add('editing'); 8181} else { 8182 sidebar.classList.remove('editing'); 8183} 8184} 8185 8186// Update page containers 8187const pageContainers = document.querySelectorAll('.page-container'); 8188pageContainers.forEach(container => { 8189if (isEditing) { 8190 container.classList.add('editing'); 8191} else { 8192 container.classList.remove('editing'); 8193} 8194}); 8195 8196// Notify all components via event 8197document.dispatchEvent(new CustomEvent('edit-mode-changed', { 8198detail: { editing: isEditing } 8199})); 8200 8201if (document.body.classList.contains('menu-layout-horizontal') && typeof window.syncContentMarginToHorizontalHeader === 'function') { 8202 window.syncContentMarginToHorizontalHeader(); 8203} 8204 8205if (typeof window.syncGalleryOptionsButtonVisibility === 'function') { 8206 window.syncGalleryOptionsButtonVisibility(); 8207} 8208 8209// Gallery Options panel styling depends on body.edit-mode-active â hide it when leaving edit mode 8210if (!isEditing && typeof window.closeOptionsPanel === 'function') { 8211 window.closeOptionsPanel(); 8212} 8213}; 8214 8215/** Show or hide gallery options (#editorButton) based on edit mode and active content type */ 8216window.syncGalleryOptionsButtonVisibility = function() { 8217 const btn = document.getElementById('editorButton'); 8218 if (!btn) return; 8219 8220 const inEditMode = document.body.classList.contains('edit-mode-active'); 8221 // When content type is explicitly gallery, ignore leftover .page-container nodes 8222 // (common when Help sandbox switches from a page to Example Gallery). 8223 const onPageView = window._activeContentType === 'gallery' 8224 ? false 8225 : (window._activeContentType === 'page' || 8226 !!document.querySelector('.gallery-container .page-container')); 8227 let isActiveGallery = false; 8228 const activeId = window.activeGalleryId; 8229 const list = window.galleries || (typeof galleries !== 'undefined' ? galleries : null); 8230 const activeItem = activeId != null && list && typeof findGalleryById === 'function' 8231 ? findGalleryById(list, activeId) 8232 : (Array.isArray(list) ? list.find(function(g) { return g && g.id === activeId; }) : null); 8233 if (activeId != null && inEditMode && !onPageView) { 8234 isActiveGallery = !!(activeItem && !isPageMenuItem(activeItem) && !activeItem.isFolder && !activeItem.isSpacer && !activeItem.isExternal); 8235 } 8236 8237 const shouldShow = inEditMode && !onPageView && isActiveGallery; 8238 if (shouldShow) { 8239 if (window.Parameters) { 8240 window.Parameters.isInEditor = true; 8241 } 8242 btn.classList.remove('gallery-options-force-hidden'); 8243 btn.style.removeProperty('display'); 8244 btn.style.setProperty('display', 'flex', 'important'); 8245 btn.style.pointerEvents = 'auto'; 8246 } else { 8247 btn.classList.add('gallery-options-force-hidden'); 8248 btn.style.setProperty('display', 'none', 'important'); 8249 btn.style.pointerEvents = ''; 8250 } 8251}; 8252 8253document.addEventListener('gallery-loaded', function() { 8254 if (typeof window.syncGalleryOptionsButtonVisibility === 'function') { 8255 window.syncGalleryOptionsButtonVisibility(); 8256 } 8257}); 8258 8259document.addEventListener('page-loaded', function() { 8260 if (typeof window.syncGalleryOptionsButtonVisibility === 'function') { 8261 window.syncGalleryOptionsButtonVisibility(); 8262 } 8263}); 8264 8265/** 8266 * Wait for PageManager after CDNâproxy late script load (ISP/Firefox clients). 8267 * Home/page bootstrap can run before the proxy copy of page-manager-v260720-021.js executes. 8268 */ 8269function whenPageManagerReady(callback, options) { 8270 const maxAttempts = (options && options.maxAttempts) || 50; 8271 const intervalMs = (options && options.intervalMs) || 100; 8272 let attempts = 0; 8273 const run = function() { 8274 if (window.PageManager && typeof window.PageManager.loadPage === 'function') { 8275 callback(window.PageManager); 8276 return; 8277 } 8278 attempts += 1; 8279 if (attempts >= maxAttempts) { 8280 console.error('â [loadPage] Timed out waiting for PageManager after', attempts, 'attempts');
8281 if (options && typeof options.onTimeout === 'function') options.onTimeout(); 8282 return; 8283 } 8284 if (attempts === 1 || attempts % 10 === 0) { 8285 console.warn('â³ [loadPage] Waiting for PageManager (CDN proxy late load?) attempt', attempts + '/' + maxAttempts); 8286 } 8287 setTimeout(run, intervalMs); 8288 }; 8289 run(); 8290} 8291 8292/** 8293 * loadPage function that safely handles undefined galleries 8294 * @param {number} id - The gallery ID 8295 * @param {Event} event - Optional event object 8296 */ 8297function loadPage(id, event){ 8298 if (event && event.target && event.target.isConnected === false) { 8299 console.log('loadPage: Ignoring navigation from detached element'); 8300 return; 8301 } 8302 if (event) { 8303 event.preventDefault(); 8304 event.stopPropagation(); 8305 const now = Date.now(); 8306 const lastCallTime = window._lastPageLoadTime || 0; 8307 window._lastPageLoadTime = now; 8308 if (now - lastCallTime < 100) { 8309 console.log('Ignoring duplicate loadPage call'); 8310 return; 8311 } 8312 } 8313 console.log('loadPage: Loading page with gallery ID:', id); 8314 8315 stopAutoAdvanceTimer(); 8316 if (typeof dismissGalleryPasswordOverlay === 'function') { 8317 dismissGalleryPasswordOverlay(); 8318 } 8319 8320 let galleriesData = window.galleries || galleries; 8321 const gallery = findGalleryById(galleriesData, id); 8322 8323 if (!gallery) { 8324 console.warn('No gallery found with ID:', id, 'for page load.'); 8325 return; 8326 } 8327 8328 const contentLoadGeneration = beginHydraContentLoad('page', gallery.id, gallery.pageId || `page_${id}`); 8329 8330 window.activeGalleryId = id; 8331 activeGalleryId = id; 8332 window._activeContentType = 'page'; 8333 if (window.EnhancedGallerySystem) { 8334 window.EnhancedGallerySystem.currentGalleryId = null; 8335 } 8336 8337 if (typeof window.removeGalleryScriptsWithPause === 'function') { 8338 window.removeGalleryScriptsWithPause(); 8339 } 8340 8341 const galleryContainer = document.querySelector('.gallery-container'); 8342 if (galleryContainer) { 8343 galleryContainer.innerHTML = ''; 8344 } 8345 8346 if (!gallery.pageId) gallery.pageId = `page_${id}`; 8347 if (!gallery.isPage) gallery.isPage = true; 8348 8349 if (typeof window.syncGalleryOptionsButtonVisibility === 'function') { 8350 window.syncGalleryOptionsButtonVisibility(); 8351 } 8352 8353 if (!window._pageIdToGalleryId) window._pageIdToGalleryId = {}; 8354 window._pageIdToGalleryId[gallery.pageId] = id; 8355 8356 if (gallery.pageElements && Array.isArray(gallery.pageElements)) { 8357 if (window.PageManager && window.PageManager.elements) { 8358 window.PageManager.elements[gallery.pageId] = [...gallery.pageElements]; 8359 } 8360 } else if (window.PageManager && window.PageManager.elements && 8361 window.PageManager.elements[gallery.pageId] && 8362 window.PageManager.elements[gallery.pageId].length > 0) { 8363 gallery.pageElements = [...window.PageManager.elements[gallery.pageId]]; 8364 } 8365 8366 if (window.PageManager && typeof window.PageManager.loadPage === 'function') { 8367 try { 8368 window.PageManager.loadPage(gallery.pageId); 8369 const isCurrentlyEditing = typeof isInEditMode === 'function' ? isInEditMode() : false; 8370 if (isCurrentlyEditing && typeof window.PageManager.setEditMode === 'function') { 8371 window.PageManager.setEditMode(isCurrentlyEditing); 8372 } 8373 } catch (error) { 8374 console.error('Error loading page with PageManager:', error); 8375 } 8376 } else { 8377 console.error('PageManager not found yet â waiting for CDN proxy script'); 8378 whenPageManagerReady(function() { 8379 try { 8380 if (!isHydraContentLoadCurrent(contentLoadGeneration, 'page', gallery.id)) { 8381 console.log('loadPage: Skipping stale PageManager retry for', gallery.pageId); 8382 return; 8383 } 8384 if (gallery.pageElements && Array.isArray(gallery.pageElements) && window.PageManager.elements) { 8385 window.PageManager.elements[gallery.pageId] = [...gallery.pageElements]; 8386 } 8387 window.PageManager.loadPage(gallery.pageId); 8388 console.log('loadPage: PageManager became available â page render retried'); 8389 } catch (error) { 8390 console.error('Error loading page with PageManager after wait:', error); 8391 } 8392 }); 8393 } 8394 8395 // Apply menu visibility 8396 const bodyEl = document.body; 8397 if (shouldApplyMenuHiddenForEmbedOrGallery(gallery)) { 8398 bodyEl.classList.add('menu-hidden-on-page'); 8399 } else { 8400 bodyEl.classList.remove('menu-hidden-on-page'); // Ensure menu is visible if in edit mode or not hidden 8401 } 8402 applyMenuOverlayClassForGallery(gallery); 8403 8404 if (typeof updateActiveStates === 'function') updateActiveStates(); 8405 if (typeof updateMobileTitle === 'function') updateMobileTitle(); 8406 if (typeof closeMobileMenu === 'function') closeMobileMenu(); 8407 // Close the gallery options panel when navigating to a different page 8408 if (typeof window.closeOptionsPanel === 'function') { 8409 window.closeOptionsPanel(); 8410 } 8411 if (typeof updateURLWithGallerySlug === 'function') updateURLWithGallerySlug(gallery); 8412 if (typeof window.syncGalleryOptionsButtonVisibility === 'function') { 8413 window.syncGalleryOptionsButtonVisibility(); 8414 } 8415 if (typeof window !== 'undefined' && typeof document !== 'undefined') { 8416 window.dispatchEvent(new CustomEvent('page-loaded', { 8417 detail: { pageId: gallery.pageId, galleryId: id } 8418 })); 8419 } 8420} 8421 8422 8423 8424 8425 8426function generatePageId() { 8427const characters = 'abcdefghijklmnopqrstuvwxyz0123456789'; 8428let result = ''; 8429const charactersLength = characters.length; 8430for (let i = 0; i < 8; i++) { 8431 result += characters.charAt(Math.floor(Math.random() * charactersLength)); 8432} 8433return result; 8434}; 8435 8436 8437function normalizeNameForComparison(name) { 8438if (!name) {
8439console.log("Empty name passed to normalization"); 8440return ''; 8441} 8442 8443console.log(`Normalizing name: "${name}"`); 8444 8445// Step 1: Convert to lowercase 8446let normalized = name.toLowerCase(); 8447console.log(`After lowercase: "${normalized}"`); 8448 8449// Step 2: Replace spaces with hyphens (without regex) 8450normalized = normalized.split(' ').join('-'); 8451console.log(`After space replacement: "${normalized}"`); 8452 8453// Step 3: Filter out unwanted characters (without regex) 8454let filtered = ''; 8455for (let i = 0; i < normalized.length; i++) { 8456const char = normalized.charAt(i); 8457// Keep only a-z, 0-9, and hyphens 8458if ((char >= 'a' && char <= 'z') || 8459 (char >= '0' && char <= '9') || 8460 char === '-') { 8461 filtered += char; 8462} 8463} 8464normalized = filtered; 8465console.log(`After character filtering: "${normalized}"`); 8466 8467// Step 4: Clean up multiple hyphens (without regex) 8468while (normalized.indexOf('--') !== -1) { 8469normalized = normalized.split('--').join('-'); 8470} 8471 8472// Step 5: Remove leading and trailing hyphens (without regex) 8473if (normalized.charAt(0) === '-') { 8474normalized = normalized.substring(1); 8475} 8476if (normalized.charAt(normalized.length - 1) === '-') { 8477normalized = normalized.substring(0, normalized.length - 1); 8478} 8479 8480// Check if the result is valid 8481if (!normalized || normalized === '-') { 8482console.log(`WARNING: Normalization produced invalid result: "${normalized}"`); 8483} else { 8484console.log(`Final normalized slug: "${normalized}"`); 8485} 8486 8487return normalized; 8488} 8489 8490function ensureUniqueSlug(slug, existingGalleries) { 8491if (!slug) return 'gallery'; 8492 8493let finalSlug = slug; 8494let counter = 1; 8495 8496// Check if slug exists in any gallery 8497const slugExists = function(s) { 8498for (let i = 0; i < existingGalleries.length; i++) { 8499 if (existingGalleries[i].slug === s) { 8500 return true; 8501 } 8502} 8503return false; 8504}; 8505 8506// Keep incrementing counter until unique 8507while (slugExists(finalSlug)) { 8508finalSlug = slug + '-' + counter; 8509counter++; 8510} 8511 8512return finalSlug; 8513} 8514 8515/** 8516 * Gathers the current complete site configuration from the client-side state 8517 * and triggers a download of the data as a single JSON file. 8518 * This provides a simple way to back up or export the current site structure. 8519 */ 8520function downloadCurrentSiteJSON() { 8521 try { 8522 console.log('Gathering current site data for download...'); 8523 8524 // Get the current hostname to use in the filename. 8525 const hostname = window.location.hostname.replace('www.', ''); 8526 8527 // --- FIX: Ensure we get the most up-to-date menu styles --- 8528 // The MenuStyleCustomizer.settings object is the most reliable source 8529 // for the current styles being used on the client. 8530 const currentMenuStyles = window.MenuStyleCustomizer?.settings || {}; 8531 8532 // Get current galleries 8533 const currentGalleries = window.galleries || galleries || []; 8534 8535 // --- STREAMLINED: Process galleries without including image data --- 8536 const enhancedGalleries = currentGalleries.map(gallery => { 8537 // If this is a Classic collection gallery, add note about manual retrieval 8538 if (gallery.galleryOptions?.manualCollectionName?.startsWith('GUID=')) { 8539 const classicGuid = gallery.galleryOptions.manualCollectionName.substring(5); 8540 console.log(`Processing Classic collection gallery: ${gallery.title} (GUID: ${classicGuid})`); 8541 8542 return { 8543 ...gallery, 8544 _note: `This gallery references Classic collection GUID=${classicGuid}. To get the full image data with captions, you may need to manually fetch the Classic collection JSON from: https://storage.neonsky.app/sites:site_${classicGuid}.json`, 8545 _classicGuid: classicGuid, 8546 _requiresClassicData: true 8547 }; 8548 } 8549 8550 return gallery; 8551 }); 8552 8553 // Assemble the complete site configuration object from global state variables. 8554 // This structure mirrors what the `save-config` API endpoint expects, ensuring 8555 // the exported file is a complete and re-importable replica. 8556 const siteData = { 8557 // The core menu/page structure with enhanced Classic collection data. 8558 galleries: enhancedGalleries, 8559 8560 // Admin emails are managed server-side for security. We export the list if it's 8561 // available on the client from a previous check, but the server remains the 8562 // source of truth. A re-import will not overwrite the admin list. 8563 adminEmails: window.siteConfig?.adminEmails || [], 8564 8565 // The unique ID for the site. 8566 siteId: window.siteId || window.Parameters?.siteId || '', 8567 8568 // Custom styling rules for the menu. 8569 menuStyles: currentMenuStyles, 8570 8571 // All elements configured to appear in the sidebar. 8572 sidebarElements: window.SidebarManager?.elements || [], 8573 8574 // Global site metadata for SEO and analytics. 8575 siteMetadata: window.siteMetadata || {}, 8576 8577 // Add metadata about Classic collections that need manual data retrieval 8578 _classicCollections: enhancedGalleries 8579 .filter(g => g._requiresClassicData) 8580 .map(g => ({ 8581 title: g.title, 8582 guid: g._classicGuid, 8583 note: g._note 8584 })) 8585 }; 8586 8587 console.log('Enhanced site data collected for export:', siteData); 8588 8589 // Convert the JavaScript object to a formatted JSON string. 8590 const jsonString = JSON.stringify(siteData, null, 2); 8591 8592 // Create a Blob, which is a file-like object of immutable, raw data. 8593 const blob = new Blob([jsonString], { type: 'application/json' }); 8594 8595 // Create a temporary link element to trigger the browser's download functionality. 8596 const url = URL.createObjectURL(blob); 8597 const a = document.createElement('a'); 8598 a.href = url; 8599 a.download = `${hostname}-config.json`; // e.g., 'yoursite.com-config.json' 8600 document.body.appendChild(a); 8601 a.click(); 8602 8603 // Clean up by removing the temporary link and revoking the object URL to free up memory. 8604 document.body.removeChild(a); 8605 URL.revokeObjectURL(url); 8606 8607 console.log('Enhanced JSON download initiated successfully.'); 8608 8609 // Show a helpful message about Classic collections 8610 const classicCount = siteData._classicCollections?.length || 0; 8611 8612 if (classicCount > 0) { 8613 console.log(`Export Summary: ${classicCount} Classic collection(s) detected. The exported JSON includes gallery structure and settings, but for full image data with captions, you may
8613need to manually fetch the Classic collection JSON files.`); 8614 } 8615 8616 } catch (error) { 8617 console.error('Error creating JSON download:', error); 8618 alert('An error occurred while preparing the download. Please check the console.'); 8619 } 8620} 8621 8622// Build Hydra Classic-style NDJSON (first line: metadata; second line: imageMetadata array) 8623function buildHydraClassicNdjson(classicGuid, images) { 8624 const header = { 8625 title: 'Untitled', 8626 description: '', 8627 version: '1', 8628 isClassic: true, 8629 isClassicCollection: true, 8630 classicGuid: 'GUID=' + String(classicGuid || '') 8631 }; 8632 8633 const imageArray = Array.isArray(images) ? images.map(function(img, idx) { 8634 var path = ''; 8635 if (img) { 8636 path = img.image || img.link || img.originalUrl || (img.urls && img.urls.original) || ''; 8637 } 8638 // Derive filename base from any provided path/filename and normalize extension to .jpg 8639 var fnameBase = ''; 8640 if (typeof path === 'string' && path.length > 0) { 8641 var slash = path.lastIndexOf('/'); 8642 var raw = slash >= 0 ? path.substring(slash + 1) : path; 8643 // remove query/hash 8644 var q = raw.indexOf('?'); if (q >= 0) raw = raw.substring(0, q); 8645 var h = raw.indexOf('#'); if (h >= 0) raw = raw.substring(0, h); 8646 // strip extension if present 8647 var dot = raw.lastIndexOf('.'); 8648 fnameBase = dot > 0 ? raw.substring(0, dot) : raw; 8649 } else if (img && typeof img.filename === 'string') { 8650 var rf = img.filename; 8651 var q2 = rf.indexOf('?'); if (q2 >= 0) rf = rf.substring(0, q2); 8652 var h2 = rf.indexOf('#'); if (h2 >= 0) rf = rf.substring(0, h2); 8653 var d2 = rf.lastIndexOf('.'); 8654 fnameBase = d2 > 0 ? rf.substring(0, d2) : rf; 8655 } 8656 var ext = (img && img.metadata && typeof img.metadata.extension === 'string' && img.metadata.extension.startsWith('.')) 8657 ? img.metadata.extension : '.jpg'; 8658 var filename = fnameBase ? (fnameBase + ext) : ''; 8659 // Build CDN image URL using site GUID: https://cdn.neonsky.app/{siteGuid}/images/{filename} 8660 // Note: classicGuid is a site GUID, not an image GUID - it identifies the site, and images are stored under that site's path 8661 var guidRaw = String(classicGuid || '').replace(/^GUID=/, ''); 8662 var cdnImageUrl = filename && guidRaw ? ('https://cdn.neonsky.app/' + guidRaw + '/images/' + filename) : path; 8663 var rec = { 8664 caption: (img && (img.caption || img.description)) || '', 8665 description: (img && (img.description || img.caption)) || '', 8666 isPreview: idx === 1, 8667 title: (img && img.title) || '', 8668 'alt-text': (img && (img['alt-text'] || img.altText || '')) || '', 8669 image: cdnImageUrl || '', 8670 link: '', 8671 'link-text': '', 8672 filename: filename, 8673 isClassic: true, 8674 isClassicCollection: true, 8675 classicGuid: 'GUID=' + String(classicGuid || '') 8676 }; 8677 return rec; 8678 }) : []; 8679 8680 // Build NDJSON as: header line, imageMetadata array line, then one line per full image record 8681 var body = { imageMetadata: imageArray }; 8682 var out = [JSON.stringify(header), JSON.stringify(body)]; 8683 for (var k = 0; k < imageArray.length; k++) { 8684 out.push(JSON.stringify(imageArray[k])); 8685 } 8686 return out.join('\n') + '\n'; 8687} 8688 8689async function uploadNdjsonToTigris(siteId, pageId, ndjsonContent) { 8690 // Dual upload strategy: Fly.io (uncompressed) + Irys (compressed if <800KB) 8691 const fileName = String(siteId || '') + '_' + String(pageId || '') + '.ndjson'; 8692 const flyUrl = 'https://hydra-press-v2.fly.dev/publish'; 8693 const irysUrl = 'https://hydra-press-v2.fly.dev/upload-irys'; 8694 8695 console.error('Starting dual NDJSON upload for:', fileName); 8696 console.error('Content length:', ndjsonContent.length, 'characters'); 8697 8698 // COMPRESSION: Compress for Irys only (not Tigris) 8699 console.error('ð Compressing NDJSON with gzip for Irys...'); 8700 const encoder = new TextEncoder(); 8701 const ndjsonBytes = encoder.encode(ndjsonContent); 8702 const compressedStream = new Response(ndjsonBytes).body.pipeThrough(new CompressionStream('gzip')); 8703 const compressedBlob = await new Response(compressedStream).blob(); 8704 const compressedBytes = new Uint8Array(await compressedBlob.arrayBuffer()); 8705 8706 const originalSize = ndjsonBytes.length; 8707 const compressedSize = compressedBytes.length; 8708 const compressionRatio = ((1 - compressedSize / originalSize) * 100).toFixed(2); 8709 8710 console.error('ð Compression results:', { 8711 original: originalSize + ' bytes (' + (originalSize / 1024).toFixed(2) + ' KB)', 8712 compressed: compressedSize + ' bytes (' + (compressedSize / 1024).toFixed(2) + ' KB)', 8713 ratio: compressionRatio + '%' 8714 }); 8715 8716 const IRYS_SIZE_LIMIT = 800 * 1024; // 800KB limit for compressed Irys upload 8717 const shouldUploadToIrys = compressedSize < IRYS_SIZE_LIMIT; 8718 8719 console.error('Irys upload eligible: ' + shouldUploadToIrys + ' (compressed: ' + (compressedSize / 1024).toFixed(2) + ' KB)'); 8720 8721 // STEP 1: Upload to Irys (non-blocking, compressed if <800KB) 8722 let irysResult = null; 8723 if (shouldUploadToIrys) { 8724 try { 8725 console.error('ð Starting Irys upload (compressed, non-blocking)...'); 8726 8727 // Convert compressed bytes to base64 8728 const compressedBase64 = btoa(String.fromCharCode(...compressedBytes)); 8729 8730 const irysResp = await fetch(irysUrl, { 8731 method: 'POST', 8732 headers: { 'content-type': 'application/json' }, 8733 body: JSON.stringify({ 8734 compressedData: compressedBase64, 8735 originalSize: originalSize, 8736 compressedSize: compressedSize, 8737 siteId: siteId, 8738 pageId: pageId 8739 }) 8740 }); 8741 8742 if (irysResp.ok) { 8743 irysResult = await irysResp.json(); 8744 console.error('â Irys upload successful:', irysResult.txId); 8745 console.error(' Irys Gateway:', irysResult.urls?.irys); 8746 console.error(' ar.io Gateway:', irysResult.urls?.arweave); 8747 8748 // Log wallet balance and cost 8749 if (irysResult.walletBalance !== undefined) { 8750 console.error('ð° Wallet Balance:', irysResult.walletBalance.toFixed(6), 'AR'); 8751 } 8752 if (irysResult.uploadCost !== undefined) { 8753 const costUSD = (irysResult.uploadCost * 20).toFixed(4); 8754 if (irysResult.uploadCost === 0) { 8755 console.error('ð¸ Upload Cost: FREE (0 AR)'); 8756 } else { 8757 console.error('ð¸ Upload Cost:', irysResult.uploadCost.toFixed(6), 'AR (~$' + costUSD + ' USD at $20/AR)'); 8758 } 8759 } 8760 } else { 8761 const errorText = await irysResp.text().catch(() => 'Unknown error'); 8762 console.warn('â ï¸ Irys upload failed (non-critical):', irysResp.status, errorText); 8763 } 8764 } catch (irysError) { 8765 console.warn('â ï¸ Irys upload error (non-critical):', irysError.message); 8766 // Continue - Irys failure doesn't block Fly.io upload 8767 } 8768 } else { 8769 console.error('âï¸ Skipping Irys upload (compressed size exceeds 800KB limit)'); 8770 } 8771 8772 // STEP 2: Upload to Fly.io (CRITICAL - must succeed, uncompressed) 8773 // Hydra sites require Bearer auth - get token from TokenManager/localStorage (same as Edition Publisher) 8774 try { 8775 console.error('ð Starting Fly.io upload (uncompressed)...'); 8776 const flyHeaders = { 'content-type': 'application/json' }; 8777 let authToken = null; 8778 try { 8779 if (window.TokenManager) { 8780 if (typeof window.TokenManager.getHydraAuthToken === 'function') { 8781 try { authToken = window.TokenManager.getHydraAuthToken(); } catch (e) {} 8782 }
8783 if (!authToken && typeof window.TokenManager.getBestToken === 'function') { 8784 try { 8785 const tw = window.TokenManager.getBestToken(); 8786 authToken = tw && tw.startsWith('Bearer ') ? tw.substring(7) : tw; 8787 } catch (e) {} 8788 } 8789 } 8790 if (!authToken) authToken = localStorage.getItem('hydra_auth_token'); 8791 if (!authToken) authToken = window.authToken; 8792 if (authToken) { 8793 flyHeaders['Authorization'] = authToken.includes('.') || authToken.startsWith('hydra:') 8794 ? 'Bearer ' + authToken 8795 : (authToken.startsWith('Bearer ') ? authToken : 'Bearer hydra:' + authToken); 8796 console.error('ð Fly.io auth: token attached'); 8797 } else { 8798 console.warn('â ï¸ No auth token - Fly.io may reject Hydra site upload (401)'); 8799 } 8800 } catch (authErr) { 8801 console.warn('â ï¸ Auth token lookup failed:', authErr.message); 8802 } 8803 const flyResp = await fetch(flyUrl, { 8804 method: 'POST', 8805 headers: flyHeaders, 8806 body: JSON.stringify({ ndjsonContent, dataFileName: fileName }) 8807 }); 8808 8809 console.log('Server response status:', flyResp.status); 8810 8811 if (!flyResp.ok) { 8812 const errorText = await flyResp.text().catch(() => 'Unknown error'); 8813 console.error('â Fly.io upload failed', flyResp.status, 'for', fileName + ':', errorText); 8814 if (flyResp.status === 401) { 8815 console.error('ð¡ Tip: Open the Edition Publisher (or log in as admin) first to authenticate for Fly.io uploads.'); 8816 } 8817 return { success: false, irysResult: null }; 8818 } 8819 8820 const flyData = await flyResp.json().catch(() => null); 8821 if (flyData && flyData.urls) { 8822 console.log('â Fly.io upload successful:', flyData.urls); 8823 console.log(' Tigris URL:', flyData.urls.urlTigris); 8824 8825 // Return comprehensive result 8826 const result = { 8827 success: true, 8828 flyUrls: flyData.urls, 8829 irysResult: irysResult, 8830 uploadedTo: irysResult ? ['fly', 'irys'] : ['fly'], 8831 fileSize: compressedSize, // Irys compressed size 8832 originalFileSize: originalSize // Original uncompressed size 8833 }; 8834 8835 console.log('ð¦ Upload complete:', { 8836 destinations: result.uploadedTo.join(' + '), 8837 txId: irysResult?.txId || 'N/A', 8838 flyUrl: flyData.urls.urlTigris 8839 }); 8840 8841 return result; 8842 } else { 8843 console.warn('Fly.io upload response missing URLs:', flyData); 8844 return { success: false, irysResult: null }; 8845 } 8846 } catch (flyError) { 8847 console.error('â Fly.io upload error for', fileName + ':', flyError); 8848 return { success: false, irysResult: null }; 8849 } 8850} 8851 8852async function importClassicGalleries() { 8853 // Browser-scoped helper: mirrors server-side behavior with escaped patterns for template-safe JS 8854 function cleanDescriptionLeadingLines(text) { 8855 if (!text || typeof text !== 'string') return text; 8856 8857 let result = text; 8858 8859 // Remove leading empty TEXTFORMAT blocks: <TEXTFORMAT><P><FONT></FONT></P></TEXTFORMAT> 8860 result = result.replace( 8861 new RegExp( 8862 '^(\s*\<TEXTFORMAT[^\>]*\>\s*\<P[^\>]*\>\s*\<FONT[^\>]*\>\s*\<\/FONT\>\s*\<\/P\>\s*\<\/TEXTFORMAT\>\s*)+', 8863 'i' 8864 ), 8865 '' 8866 ); 8867 8868 // Remove leading empty P tags 8869 result = result.replace( 8870 new RegExp('^(\s*\<P[^\>]*\>\s*\<\/P\>\s*)+', 'i'), 8871 '' 8872 ); 8873 8874 // Remove leading empty FONT tags 8875 result = result.replace( 8876 new RegExp('^(\s*\<FONT[^\>]*\>\s*\<\/FONT\>\s*)+', 'i'), 8877 '' 8878 ); 8879 8880 // Remove leading BR tags 8881 result = result.replace( 8882 new RegExp('^(\s*\<br\s*\/\?>\s*)+', 'i'), 8883 '' 8884 ); 8885 8886 // Remove any remaining leading whitespace 8887 return result.replace(new RegExp('^\s+', 'i'), '').trim(); 8888 } 8889 8890 // Browser-scoped helper: fix multi-line links (template-safe escaped regex) 8891 function fixMultilineLinks(text) { 8892 if (!text || typeof text !== 'string') return text; 8893 return text.replace( 8894 new RegExp('<a[^>]*>.*?Click.*?<\/a><a[^>]*>.*?HERE.*?<\/a><a[^>]*>.*?to Add to Cart.*?<\/a>', 'gis'), 8895 function (match) { 8896 const first = match.match(new RegExp('<a href="([^"]*)" target="([^"]*)"', 'i')); 8897 if (first) { 8898 return '<a href="' + first[1] + '" target="' + first[2] + '">
8898Click HERE to Add to Cart</a>'; 8899 } 8900 return match; 8901 } 8902 ); 8903 } 8904 8905 // Browser-scoped helper: convert plain text link phrase to HTML link 8906 function convertPlainTextLinks(text) { 8907 if (!text || typeof text !== 'string') return text; 8908 return text.replace( 8909 new RegExp('Click HERE to Add to Cart', 'g'), 8910 '<a href="#" target="_blank">Click HERE to Add to Cart</a>' 8911 ); 8912 } 8913 // Get input elements, including the new checkbox 8914 const guidInputEl = document.getElementById('classicGuid'); 8915 const pastedJsonEl = document.getElementById('pastedJson'); 8916 const parentIdInputEl = document.getElementById('importParent'); 8917 const createSubmenuCheckboxEl = document.getElementById('createSubmenu'); 8918 const submenuTitleInputEl = document.getElementById('submenuTitle'); 8919 const replaceMenuCheckboxEl = document.getElementById('replaceMenuCheckbox'); 8920 8921 // Get values from inputs 8922 const guidInput = guidInputEl instanceof HTMLInputElement ? guidInputEl.value.trim() : ''; 8923 const pastedJson = pastedJsonEl instanceof HTMLTextAreaElement ? pastedJsonEl.value.trim() : ''; 8924 const replaceMenu = replaceMenuCheckboxEl instanceof HTMLInputElement ? replaceMenuCheckboxEl.checked : false; 8925 8926 let parentId = parentIdInputEl instanceof HTMLSelectElement && parentIdInputEl.value ? parseInt(parentIdInputEl.value) : null; 8927 let createSubmenu = createSubmenuCheckboxEl instanceof HTMLInputElement ? createSubmenuCheckboxEl.checked : false; 8928 let submenuTitle = submenuTitleInputEl instanceof HTMLInputElement ? submenuTitleInputEl.value.trim() : ''; 8929 8930 // If replacing the menu, ignore parent/submenu settings 8931 if (replaceMenu) { 8932 parentId = null; 8933 createSubmenu = false; 8934 } 8935 8936 // Validate submenu title if checkbox is checked 8937 if (createSubmenu && !submenuTitle) { 8938 alert('Please provide a title for the submenu'); 8939 return; 8940 } 8941 8942 // Show status display 8943 const statusEl = document.getElementById('importStatus'); 8944 const progressBarEl = statusEl ? statusEl.querySelector('.progress-bar') : null; 8945 const statusMessageEl = statusEl ? statusEl.querySelector('.status-message') : null; 8946 const progressBar = progressBarEl instanceof HTMLElement ? progressBarEl : null; 8947 const statusMessage = statusMessageEl instanceof HTMLElement ? statusMessageEl : null; 8948 8949 if (statusEl && progressBar && statusMessage) { 8950 statusEl.style.display = 'block'; 8951 statusEl.classList.add('importing'); 8952 progressBar.style.width = '10%'; 8953 statusMessage.textContent = 'Preparing to import...'; 8954 } 8955 8956 try {
8957 let classicData; 8958 let classicGuid; // Will hold the primary GUID for image path lookups 8959 8960 // --- LOGIC TO GET DATA: Prioritize Pasted JSON --- 8961 if (pastedJson) { 8962 if (statusMessage) statusMessage.textContent = 'Parsing pasted JSON...'; 8963 classicData = JSON.parse(pastedJson); 8964 8965 if (!classicData.galleries || !Array.isArray(classicData.galleries)) { 8966 throw new Error('Pasted JSON is invalid: "galleries" array not found.'); 8967 } 8968 // Use the first available GUID from the pasted data as the default for images 8969 classicGuid = classicData.galleries[0]?.classicGuid || classicData.siteId; 8970 8971 } else if (guidInput) { 8972 // Check if this is the new import_ format 8973 if (guidInput.startsWith('import_')) { 8974 if (statusMessage) statusMessage.textContent = 'Fetching data via import_ format...'; 8975 const importGuid = guidInput.substring(7); // Remove 'import_' prefix 8976 8977 const jsonUrl = `https://hydra.neonsky.app/json-imports/${guidInput}.json`; 8978 const response = await fetch(jsonUrl); 8979 if (!response.ok) throw new Error(`HTTP error! status: ${response.status} for ${jsonUrl}`); 8980 8981 classicData = await response.json(); 8982 8983 if (!classicData.galleries || !Array.isArray(classicData.galleries)) {
8984 throw new Error('Fetched import JSON is invalid: "galleries" array not found.'); 8985 } 8986 8987 // HYBRID APPROACH: Also fetch the original GUID JSON for image metadata 8988 if (statusMessage) statusMessage.textContent = 'Fetching image metadata from original JSON...'; 8989 8990 const originalJsonUrl = `https://cdn.neonsky.app/sites:site_${importGuid}.json`; 8991 8992 try { 8993 const originalResponse = await fetch(originalJsonUrl); 8994 if (originalResponse.ok) { 8995 const originalData = await originalResponse.json(); 8996 8997 // Get the default language from site configuration 8998 let defaultLanguageID = 'en'; // fallback 8999 if (originalData.languages && Array.isArray(originalData.languages)) { 9000 const defaultLang = originalData.languages.find(lang => lang.isDefault === true); 9001 if (defaultLang) { 9002 defaultLanguageID = defaultLang.id; 9003 } 9004 } 9005 9006 // Merge image metadata from original JSON into import JSON galleries 9007 if (originalData.galleries && Array.isArray(originalData.galleries)) { 9008 classicData.galleries = classicData.galleries.map(importGallery => { 9009 // Find matching gallery in original data by name or categoryID 9010 9011 const originalGallery = originalData.galleries.find(orig => 9012 orig.title === importGallery.title || // Primary match by title 9013 orig.name === importGallery.title || // Fallback: orig.name matches import.title 9014 orig.categoryID === importGallery.categoryID // Fallback: categoryID match 9015 ); 9016 9017 9018 if (originalGallery && originalGallery.images && Array.isArray(originalGallery.images)) { 9019 9020 // Process all images from original gallery with metadata extraction 9021 const processedImages = originalGallery.images.map(originalImage => { 9022 // Apply EXACT same metadata processing logic as Edition Publisher 9023 let title = originalImage.title || ''; 9024 let dateline = originalImage.dateline || ''; 9025 let caption = originalImage.caption || ''; 9026 9027 // Check languageVersions array for metadata (this is where most data is stored) 9028 if (originalImage.languageVersions && originalImage.languageVersions.length > 0) { 9029 // Look for site's default language first, then fall back to first available 9030 let langData = originalImage.languageVersions.find(lang => lang.languageID === defaultLanguageID); 9031 if (!langData) { 9032 langData = originalImage.languageVersions[0]; 9033 } 9034 9035 // For language-enabled JSON files, prioritize languageVersions data 9036 // Only fall back to top-level if languageVersions data is empty 9037 if (!title || title.trim() === '') title = langData.title || ''; 9038 if (!dateline || dateline.trim() === '') dateline = langData.dateline || ''; 9039 if (!caption || caption.trim() === '') caption = langData.caption || ''; 9040 } 9041 9042 // Convert Flash TEXTFORMAT to HTML (properly escaped for template literals) 9043 let description = caption; 9044 if (caption && caption.includes('TEXTFORMAT')) { 9045 try { 9046 // Use properly escaped HTML characters for template literal context 9047 description = caption 9048 .replace(/\<TEXTFORMAT[^\>]*\>/g, '') 9049 .replace(/\<\/TEXTFORMAT\>/g, '') 9050 .replace(/\<P[^\>]*\>/g, '\<p\>') 9051 .replace(/\<\/P\>/g, '\<\/p\>') 9052 .replace(/\<FONT[^\>]*\>/g, '') 9053 .replace(/\<\/FONT\>/g, '') 9054 .replace(/\<A HREF="([^"]*)"[^\>]*\>/g, '\<a href="$1" target="_blank"\>') 9055 .replace(/\<\/A\>/g, '\<\/a\>') 9056 .replace(/\<U\>/g, '\<u\>') 9057 .replace(/\<\/U\>/g, '\<\/u\>') 9058 .replace(/\<B\>/g, '\<strong\>') 9059 .replace(/\<\/B\>/g, '\<\/strong\>') 9060 .replace(/\<I\>/g, '\<em\>') 9061 .replace(/\<\/I\>/g, '\<\/em\>') 9062 .replace(/"/g, '"') 9063 .replace(/'/g, "'") 9064 .replace(/\s+/g, ' ') 9065 .trim(); 9066 } catch (error) { 9067 console.warn('TEXTFORMAT conversion failed, using original caption:', error); 9068 description = caption; 9069 } 9070 } 9071 9072 // Prepend dateline to description if it exists (convert to uppercase) 9073 if (dateline) { 9074 const uppercaseDateline = dateline.toUpperCase(); 9075 description = `${uppercaseDateline} ${description}`.trim(); 9076 } 9077 9078 // Apply description fixes 9079 description = cleanDescriptionLeadingLines(description); 9080 description = fixMultilineLinks(description); 9081 description = convertPlainTextLinks(description); 9082 9083 const imageEntry = { 9084 // Match EXACT structure from Edition Publisher hydraImageEntry 9085 image: originalImage.image || originalImage.urls?.original || '', 9086 caption: description, // Use processed description as caption 9087 description: description, // ALSO set description field (like Edition Publisher) 9088 title: title, 9089 'alt-text': title, // Use title as alt-text like Edition Publisher 9090 link: originalImage.urls?.original || '', 9091 'link-text': originalImage.urls?.original || '', 9092 isPreview: false, 9093 filename: originalImage.filename || '', 9094 originalUrl: originalImage.image || originalImage.urls?.original || '', 9095 dateline: dateline 9096 }; 9097 9098 return imageEntry; 9099 }); 9100 9101 return { 9102 ...importGallery, // Keep import gallery structure (order, settings, etc.) 9103 images: processedImages // Replace with processed original images 9104 }; 9105 } 9106 return importGallery; 9107 }); 9108 } 9109 } else { 9110 console.warn('Could not fetch original JSON for metadata, using import data only'); 9111 } 9112 } catch (error) { 9113 console.warn('Error fetching original JSON for metadata:', error); 9114 } 9115 9116 // Extract original GUID for image path lookups 9117 // If importGuid has a suffix (e.g., "4bd5ebf7bda9d-all"), extract the base GUID 9118 // GUIDs are typically 13 characters, so split on common separators 9119 let baseGuid = importGuid; 9120 const guidMatch = importGuid.match(/^([a-f0-9]{13})(?:-.*)?$/i); 9121 if (guidMatch) { 9122 baseGuid = guidMatch[1]; 9123 } else { 9124 // Fallback: try to extract first 13-character hex string 9125 const hexMatch = importGuid.match(/^([a-f0-9]{13})/i); 9126 if (hexMatch) { 9127 baseGuid = hexMatch[1]; 9128 } 9129 } 9130 9131 // Use the base GUID for image path lookups (e.g., /images/filename.jpg) 9132 classicGuid = baseGuid; 9133 console.log('Successfully loaded hybrid import data with import GUID:', importGuid); 9134 console.log('Using base GUID for image paths:', classicGuid); 9135 9136 } else { 9137 // Standard GUID format - existing logic 9138 if (statusMessage) statusMessage.textContent = 'Fetching data via GUID...';
9139 classicGuid = guidInput.startsWith('GUID=') ? guidInput.substring(5) : guidInput; 9140 console.log('Detected standard GUID format, GUID:', classicGuid); 9141 9142 const jsonUrl = `https://storage.neonsky.app/sites:site_${classicGuid}.json`; 9143 console.log('Fetching classic JSON from:', jsonUrl); 9144 const response = await fetch(jsonUrl); 9145 9146 if (!response.ok) throw new Error(`HTTP error! status: ${response.status} for ${jsonUrl}`); 9147 classicData = await response.json(); 9148 9149 if (!classicData.galleries || !Array.isArray(classicData.galleries)) { 9150 throw new Error('Fetched JSON is invalid: "galleries" array not found.'); 9151 } 9152 9153 console.log('Successfully loaded classic data with GUID:', classicGuid); 9154 } 9155 9156 } else { 9157 alert('Please either paste site JSON or enter a Classic GUID to import.'); 9158 if (statusEl) statusEl.style.display = 'none'; 9159 return; 9160 } 9161 9162 const galleriesForImport = classicData.galleries; 9163 if (statusMessage) statusMessage.textContent = `Found ${galleriesForImport.length} items to import...`; 9164 9165 // import_ format: galleries come from DB/import JSON, not classic site - treat as new Hydra NDJSON galleries 9166 const isImportFormat = !!(guidInput && guidInput.startsWith('import_')); 9167 9168 // --- HIERARCHY RECONSTRUCTION LOGIC --- 9169 9170 const currentSiteId = window.Parameters?.siteId || siteId; 9171 let submenuParentId = parentId; 9172 const tempNewItems = []; 9173 const originalIdToNewIdMap = {}; 9174 9175 // Create the optional top-level submenu if requested 9176 if (createSubmenu) { 9177 const submenuItem = { 9178 id: Date.now(), 9179 title: submenuTitle, 9180 isSubmenu: true, 9181 isCollapsed: true, 9182 visible: true, 9183 parentId: parentId, 9184 siteId: currentSiteId, 9185 children: [] 9186 }; 9187 galleries.push(submenuItem); 9188 submenuParentId = submenuItem.id; 9189 } 9190 9191 // --- Pass 1: Create new items with correct data and map old IDs to new IDs --- 9192 for (let index = 0; index < galleriesForImport.length; index++) { 9193 const classicItem = galleriesForImport[index]; 9194 const newItemId = Date.now() + index + 100; 9195 originalIdToNewIdMap[classicItem.id] = newItemId; 9196 9197 if (progressBar) progressBar.style.width = `${40 + Math.round(index * 50 / galleriesForImport.length)}%`; 9198 if (statusMessage) statusMessage.textContent = `Processing item ${index + 1} of ${galleriesForImport.length}...`; 9199 9200 const title = classicItem.title || classicItem.languageVersions?.[0]?.title || classicItem.name || `Item ${classicItem.categoryID}`; 9201 9202 let slug; 9203 // If pasting from JSON, *always* regenerate the slug from the title to ensure it's clean. 9204 // Otherwise (for GUID import), trust the existing slug if it's there. 9205 if (pastedJson) { 9206 slug = normalizeNameForComparison(title); 9207 } else if (classicItem.slug && classicItem.slug.trim() !== '') { 9208 slug = classicItem.slug; 9209 } else { 9210 slug = normalizeNameForComparison(title); 9211 } 9212const finalSlug = ensureUniqueSlug(slug || 'gallery', [...galleries, ...tempNewItems]); // Ensure uniqueness 9213 9214 // Determine the GUID to use for this specific item's image paths 9215 const itemGuid = classicItem.classicGuid || classicGuid; 9216 9217 let newItem; 9218 9219 // --- Check if it's an External URL --- 9220 if (classicItem.isExternal === true && classicItem.url) { 9221 console.log(`Creating external URL menu item: "${title}" -> ${classicItem.url}`); 9222 newItem = { 9223 id: newItemId, 9224 title, 9225 url: classicItem.url, 9226 isExternal: true, 9227 visible: classicItem.visible !== false, 9228 parentId: null, 9229 siteId: currentSiteId, 9230 slug: finalSlug, 9231 importSource: 'external-url' 9232 }; 9233 } 9234 // --- Check if it's a Page and build its elements --- 9235 else if (classicItem.isPage === true || classicItem.type === 3 || classicItem.behaviorID === 3) { 9236 const pageUniqueId = `page_${newItemId}`; 9237 9238 let pageElements = []; 9239 9240 // ================================================================= 9241 // ENHANCED: Handle pageElements for both pasted JSON and import_guid.json format 9242 // ================================================================= 9243 if (classicItem.pageElements && Array.isArray(classicItem.pageElements)) { 9244 console.log(`Using existing pageElements for page: "${title}"`); 9245 9246 // Check if we have both pageElements and images - create two-column layout 9247 if (classicItem.images && classicItem.images.length > 0) { 9248 console.log(`Creating two-column layout for page: "${title}" with ${classicItem.images.length} images`); 9249 9250 // Create metadata element 9251 pageElements.push({ 9252 id: newItemId + 1000, type: "metadata", title: "Metadata", visible: true, position: 0, 9253 metaTitle: classicItem.meta?.title || title, 9254 metaDescription: classicItem.meta?.description || "", 9255 metaKeywords: classicItem.meta?.keywords || "" 9256 }); 9257 9258 // Create two-column container 9259 const leftColumnElements = []; 9260 const rightColumnElements = []; 9261 9262 // Add images to left column 9263 const slides = classicItem.images.map((img) => { 9264 const imageFilename = (img.filename || `image_${Date.now()}`) + (img.metadata?.extension || '.jpg'); 9265 const imageKey = `${itemGuid}/images/${imageFilename}`; 9266 const imageUrl = `https://storage.neonsky.app/${imageKey}`; 9267 9268 return { 9269 imageUrl, 9270 imageKey, 9271 imageFilename, 9272 imageType: img.metadata?.mimetype || 'image/jpeg', 9273 caption: cleanDescriptionLeadingLines(fixMultilineLinks(convertPlainTextLinks(img.caption || ''))), 9274 title: img.title || '', 9275 byline: img.byline || '', 9276 dateline: img.dateline || '', 9277 altText: img.metadata?.alt_text || '' 9278 }; 9279 }); 9280 9281 leftColumnElements.push({ 9282 id: newItemId + 3100, type: "slideshow", title: "Page Images", visible: true, position: 0, 9283 slides, slideDuration: 5000, transitionDuration: 500, slideshowWidth: 88, slideshowHeight: 50, showFullImages: true 9284 }); 9285 9286 // Add text content to right column
9287 classicItem.pageElements.forEach((el, elIndex) => { 9288 if (el.type === 'text' && el.content) { 9289 rightColumnElements.push({ 9290 id: newItemId + 3200 + elIndex, type: "text", title: "Text Block", visible: true, position: elIndex, 9291 textContent: el.content, textWidth: 70 9292 }); 9293 } 9294 }); 9295 9296 const columns = [ 9297 { id: newItemId + 3300, hAlign: "right", vAlign: "middle", elements: leftColumnElements }, 9298 { id: newItemId + 3400, hAlign: "left", vAlign: "middle", elements: rightColumnElements } 9299 ]; 9300 9301 pageElements.push({ 9302 id: newItemId + 3000, type: "column-container", title: "Two Column Layout", visible: true, position: 1, 9303 columns, 9304 backgroundSlideshow: { enabled: false, slides: [], slideDuration: 5000, transitionDuration: 500, slideshowHeight: 50, showFullImages: false, showSingleRandomImage: false, randomStart: false }, 9305 backgroundVideo: { enabled: false, videoUrl: '', videoUrls: [], randomizeVideo: false, videoHeight: 50 } 9306 }); 9307 9308 } else { 9309 // No images, just transform the pageElements as before 9310 pageElements = classicItem.pageElements.map((el, elIndex) => { 9311 const elementId = Date.now() + index + 1000 + elIndex; 9312 9313 // Handle import_guid.json format (type + content) 9314 if (el.type && el.content) { 9315 if (el.type === 'text') { 9316 return { 9317 id: elementId, 9318 type: 'text', 9319 title: 'Text Block', 9320 visible: true, 9321 position: elIndex, 9322 textContent: el.content, 9323 textWidth: 100 9324 }; 9325 } else if (el.type === 'metadata') { 9326 return { 9327 id: elementId, 9328 type: 'metadata', 9329 title: 'Metadata', 9330 visible: true, 9331 position: elIndex, 9332 metaTitle: el.metaTitle || title, 9333 metaDescription: el.metaDescription || '', 9334 metaKeywords: el.metaKeywords || '' 9335 }; 9336 } 9337 } 9338 9339 // Handle existing format (already has id, position, etc.) 9340 return { 9341 ...el, 9342 id: elementId, 9343 position: el.position !== undefined ? el.position : elIndex 9344 }; 9345 }); 9346 } 9347 9348 } else { 9349 // Otherwise (for GUID-based import), generate the page structure from scratch. 9350 console.log(`Generating new pageElements for page: "${title}"`); 9351 pageElements.push({ 9352 id: newItemId + 1000, type: "metadata", title: "Metadata", visible: true, position: 0, 9353 metaTitle: classicItem.meta?.title || title, 9354 metaDescription: classicItem.meta?.description || "", 9355 metaKeywords: classicItem.meta?.keywords || "" 9356 }); 9357 9358 // Add images if they exist in the page 9359 if (classicItem.images && classicItem.images.length > 0) { 9360 const slides = classicItem.images.map((img) => { 9361 const imageFilename = (img.filename || `image_${Date.now()}`) + (img.metadata?.extension || '.jpg'); 9362 const imageKey = `${itemGuid}/images/${imageFilename}`; 9363 const imageUrl = `https://storage.neonsky.app/${imageKey}`; 9364 9365 return { 9366 imageUrl, 9367 imageKey, 9368 imageFilename, 9369 imageType: img.metadata?.mimetype || 'image/jpeg', 9370 caption: cleanDescriptionLeadingLines(fixMultilineLinks(convertPlainTextLinks(img.caption || ''))), 9371 title: img.title || '', 9372 byline: img.byline || '', 9373 dateline: img.dateline || '', 9374 altText: img.metadata?.alt_text || '' 9375 }; 9376 }); 9377 9378 pageElements.push({ 9379 id: newItemId + 1500, type: "slideshow", title: "Page Images", visible: true, position: 1, 9380 slides, slideDuration: 5000, transitionDuration: 500, slideshowWidth: 100, slideshowHeight: 60, showFullImages: true 9381 }); 9382 } 9383 9384 pageElements.push({ 9385 id: newItemId + 2000, type: "text", title: "Text Block Spacer", visible: true, position: 2, 9386 textContent: "<p> </p><p><br></p>", textWidth: 100 9387 }); 9388 9389 const columns = []; 9390 const leftColumnElements = []; 9391 const rightColumnElements = []; 9392 9393 if (classicItem.images && classicItem.images.length > 0) { 9394 const slides = classicItem.images.map((img) => { 9395 const imageFilename = (img.filename || `image_${Date.now()}`) + (img.metadata?.extension || '.jpg'); 9396 const imageKey = `${itemGuid}/images/${imageFilename}`; 9397 const imageUrl = `https://storage.neonsky.app/${imageKey}`; 9398 9399 // Preserve all image metadata including captions, titles, etc. 9400 return { 9401 imageUrl, 9402 imageKey, 9403 imageFilename, 9404 imageType: img.metadata?.mimetype || 'image/jpeg', 9405 // Preserve caption information - handle both direct and language version storage patterns 9406 caption: cleanDescriptionLeadingLines(fixMultilineLinks(convertPlainTextLinks(img.caption || img.languageVersions?.[0]?.caption || ''))), 9407 title: img.title || img.languageVersions?.[0]?.title || '', 9408 byline: img.byline || img.languageVersions?.[0]?.byline || '', 9409 dateline: img.dateline || img.languageVersions?.[0]?.dateline || '', 9410 altText: img.metadata?.alt_text || '', 9411 // Preserve any additional metadata 9412 metaTitle: img.metadata?.meta_title || '', 9413 metaDescription: img.metadata?.meta_description || '', 9414 metaKeywords: img.metadata?.meta_keywords || '', 9415 // Preserve gallery-specific data 9416 sortOrder: img.galleryData?.sortOrder || 0, 9417 linkURL: img.galleryData?.linkURL || null, 9418 linkTarget: img.galleryData?.linkTarget || '_self' 9419 }; 9420 }); 9421 leftColumnElements.push({ 9422 id: newItemId + 3100, type: "slideshow", title: "Image Slideshow", visible: true, position: 0, 9423 slides, slideDuration: 5000, transitionDuration: 500, slideshowWidth: 88, slideshowHeight: 50, showFullImages: true 9424 }); 9425 } 9426 9427 let rawDetails = classicItem.details || classicItem.languageVersions?.[0]?.details || ""; 9428 if (rawDetails && typeof rawDetails === 'string') { 9429 // ================================================================= 9430 // CHANGE 2: Fix for "Invalid regular expression flags" error. 9431 // Changed from /.../gi literal to new RegExp('...', 'gi') constructor 9432 // to prevent syntax errors. 9433 // ================================================================= 9434 let processedText = rawDetails.replace(new RegExp('<TEXTFORMAT.*?>', 'gi'), '').replace(new RegExp('</TEXTFORMAT>', 'gi'), ''); 9435 processedText = processedText.replace(new RegExp('<P ALIGN=".*?"', 'gi'), '<p').replace(new RegExp('</P>', 'gi'), '</p>'); 9436 processedText = processedText.replace(new RegExp('<FONT.*?>', 'gi'), '').replace(new RegExp('</FONT>', 'gi'), ''); 9437 processedText = processedText.replace(new RegExp('<B>
9437', 'gi'), '<strong>').replace(new RegExp('</B>', 'gi'), '</strong>'); 9438 processedText = processedText.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); 9439 9440 // Apply description fixes to processed text 9441 processedText = cleanDescriptionLeadingLines(processedText); 9442 processedText = fixMultilineLinks(processedText); 9443 processedText = convertPlainTextLinks(processedText); 9444 9445 rightColumnElements.push({ 9446 id: newItemId + 3200, type: "text", title: "Text Block Content", visible: true, position: 0, 9447 textContent: processedText, textWidth: 70 9448 }); 9449 } 9450 9451 columns.push({ id: newItemId + 3300, hAlign: "right", vAlign: "middle", elements: leftColumnElements }); 9452 columns.push({ id: newItemId + 3400, hAlign: "left", vAlign: "middle", elements: rightColumnElements }); 9453 9454 pageElements.push({ 9455 id: newItemId + 3000, type: "column-container", title: "Imported Columns", visible: true, position: 2, 9456 columns, 9457 backgroundSlideshow: { enabled: false, slides: [], slideDuration: 5000, transitionDuration: 500, slideshowHeight: 50, showFullImages: false, showSingleRandomImage: false, randomStart: false }, 9458 backgroundVideo: { enabled: false, videoUrl: '', videoUrls: [], randomizeVideo: false, videoHeight: 50 } 9459 }); 9460 } 9461 9462 newItem = { 9463 id: newItemId, title, isPage: true, isIntegrated: true, isSubmenu: false, pageId: pageUniqueId, visible: classicItem.visible !== false, parentId: null, 9464 siteId: currentSiteId, slug: finalSlug, url: `/${finalSlug}`, pageElements, 9465 classicCategoryId: classicItem.categoryID, classicGuid: itemGuid, importSource: 'classic-page' 9466 }; 9467 } else if (classicItem.isFolder === true) { 9468 // --- Handle folders from import_guid.json format --- 9469 console.log(`Creating folder: "${title}"`); 9470 newItem = { 9471 id: newItemId, 9472 title, 9473 isFolder: true, 9474 isCollapsed: classicItem.isCollapsed !== false, 9475 visible: classicItem.visible !== false, 9476 parentId: null, 9477 siteId: currentSiteId, 9478 slug: finalSlug, 9479 importSource: 'folder' 9480 }; 9481 } else { 9482 // --- This is a Gallery --- 9483 const galleryPageId = generatePageId(); 9484 9485 // --- ENHANCED: Try to fetch and include actual image data with captions --- 9486 let enhancedGalleryOptions = { 9487 manualCollectionName: `GUID=${itemGuid}`, 9488 categoryId: classicItem.categoryID, 9489 importedFromClassic: true, 9490 siteId: currentSiteId, 9491 pageId: galleryPageId 9492 }; 9493 9494 // --- ENHANCED: Preserve gallery display settings from Classic JSON --- 9495 if (classicItem.galleryOptions) { 9496 console.log(`Preserving gallery settings for: ${title}`); 9497 9498 // Preserve layout and display settings 9499 const preservedSettings = [ 9500 'startInSingles', 'layoutType', 'columns', 'spacing', 9501 'showDescription', 'showFilename', 'displayAllInfo', 9502 'navigationMode', 'showTextBlock', 'fixedHeroImage', 9503 'zoomInLightbox', 'lightboxOnMobile', 'fadeDuration', 9504 'fadeInDuration', 'descriptionTextColor', 'gridImageOverlayColor', 9505 'gridImageOverlayOpacity', 'lightboxBgColor', 'lightboxBgOpacity', 9506 'lightboxCloseColor', 'lightboxArrowColor', 'useTitles', 9507 'useLinks', 'desktopTitleDisplayMode', 'titleTextAlign', 9508 'showDescriptionInOverlay', 'includeRolloverImageInOverlay', 9509 'filterMenuEnabled', 'filterMenuCollectionNames', 'filterMenuTitles', 9510 'filterMenuStyle', 'rolloverSwap', 'rolloverCollectionNames', 9511 'openMultipleLightboxes', 'batchSize', 'autoplaySingles', 9512 'autoplayDuration', 'autoplayTransition', 'autoplayRandomStart' 9513 ]; 9514
9515 preservedSettings.forEach(setting => { 9516 if (classicItem.galleryOptions[setting] !== undefined) { 9517 enhancedGalleryOptions[setting] = classicItem.galleryOptions[setting]; 9518 } 9519 }); 9520 9521 // Preserve text content 9522 if (classicItem.galleryOptions.horizontalScrollerText) { 9523 enhancedGalleryOptions.horizontalScrollerText = classicItem.galleryOptions.horizontalScrollerText; 9524 } 9525 9526 // Preserve JSON collections if present 9527 if (classicItem.galleryOptions.jsonCollections) { 9528 enhancedGalleryOptions.jsonCollections = classicItem.galleryOptions.jsonCollections; 9529 } else { 9530 // Create jsonCollections structure with processed image metadata 9531 const collectionKey = 'GUID=' + itemGuid; 9532 const processedImages = classicItem.images || []; 9533 9534 enhancedGalleryOptions.jsonCollections = { 9535 [collectionKey]: { 9536 isClassicCollection: true, 9537 guid: collectionKey, 9538 metadata: processedImages, 9539 totalItems: processedImages.length 9540 } 9541 }; 9542 9543 console.log('Created jsonCollections for ' + title + ' with ' + processedImages.length + ' images'); 9544 9545 // Build and try to upload NDJSON for this gallery. 9546 // If upload succeeds, switch to metadata = {} so frontend fetches NDJSON. 9547 // Skip NDJSON generation for galleries with no images to avoid creating empty files 9548 if (processedImages.length > 0) { 9549 try { 9550 console.log('Building and uploading NDJSON for gallery:', title, '(' + processedImages.length + ' images)'); 9551 const ndjsonContent = buildHydraClassicNdjson(itemGuid, processedImages); 9552 console.log('Generated NDJSON content length:', ndjsonContent.length, 'characters'); 9553 9554 const uploadResult = await uploadNdjsonToTigris(currentSiteId, galleryPageId, ndjsonContent); 9555 if (uploadResult && uploadResult.success) { 9556 try { 9557 // import_ format: treat as new Hydra NDJSON gallery (not classic-dependent) 9558 if (isImportFormat) { 9559 const hydraCollectionKey = 'GUID=' + currentSiteId; 9560 enhancedGalleryOptions.manualCollectionName = hydraCollectionKey; 9561 enhancedGalleryOptions.jsonCollections = { 9562 [hydraCollectionKey]: { 9563 isClassicCollection: false, 9564 guid: hydraCollectionKey, 9565 metadata: {}, 9566 totalItems: processedImages.length 9567 } 9568 }; 9569 enhancedGalleryOptions.siteAlias = window.Parameters?.SiteAlias || window.location?.hostname || currentSiteId; 9570 enhancedGalleryOptions.initialPageUuid = galleryPageId; 9571 enhancedGalleryOptions.isHydra = true; 9572 enhancedGalleryOptions.flyIoSaveTimestamp = Date.now(); 9573 console.log('NDJSON uploaded for import_ gallery "' + title + '" - configured as Hydra NDJSON (not classic)'); 9574 } else { 9575 enhancedGalleryOptions.jsonCollections[collectionKey].metadata = {}; 9576 enhancedGalleryOptions.jsonCollections[collectionKey].totalItems = processedImages.length; 9577 } 9578 9579 // Store dual upload metadata if Irys was successful 9580 if (uploadResult.irysResult && uploadResult.irysResult.txId) { 9581 enhancedGalleryOptions.txId = uploadResult.irysResult.txId; 9582 enhancedGalleryOptions.permaURL = uploadResult.irysResult.txId; // Plain txId for racing 9583 enhancedGalleryOptions.isPerma = true; 9584 enhancedGalleryOptions.uploadedTo = uploadResult.uploadedTo; 9585 enhancedGalleryOptions.fileSize = uploadResult.fileSize; 9586 enhancedGalleryOptions.siteId = currentSiteId; 9587 enhancedGalleryOptions.pageId = galleryPageId; 9588 console.log('â Dual upload complete - Irys txId:', uploadResult.irysResult.txId); 9589 console.log(' Uploaded to:', uploadResult.uploadedTo.join(' + ')); 9590 } else { 9591 console.log('â Single upload (Fly.io only) - No Irys data'); 9592 } 9593 } catch (applyErr) { 9594 console.warn('Failed to switch metadata to {} after NDJSON upload:', applyErr); 9595 } 9596 } else { 9597 console.warn('NDJSON upload failed for "' + title + '"; keeping inline metadata array'); 9598 } 9599 } catch (e) { 9600 console.warn('NDJSON build/upload skipped for "' + title + '" due to error:', e); 9601 } 9602 } else {
9603 console.log('Skipping NDJSON generation for "' + title + '" - no images to process'); 9604 } 9605 } 9606 9607 // Preserve additional metadata 9608 if (classicItem.galleryOptions.siteAlias) { 9609 enhancedGalleryOptions.siteAlias = classicItem.galleryOptions.siteAlias; 9610 } 9611 if (classicItem.galleryOptions.initialPageUuid) { 9612 enhancedGalleryOptions.initialPageUuid = classicItem.galleryOptions.initialPageUuid; 9613 } 9614 if (classicItem.galleryOptions.initialPageAlias) { 9615 enhancedGalleryOptions.initialPageAlias = classicItem.galleryOptions.initialPageAlias; 9616 } 9617 if (classicItem.galleryOptions.timestamp) { 9618 enhancedGalleryOptions.timestamp = classicItem.galleryOptions.timestamp; 9619 } 9620 } else if (isImportFormat && (classicItem.images || []).length > 0) { 9621 // import_ format without galleryOptions: still create NDJSON for new Hydra galleries 9622 const processedImages = classicItem.images || []; 9623 const collectionKey = 'GUID=' + itemGuid; 9624 enhancedGalleryOptions.jsonCollections = { 9625 [collectionKey]: { 9626 isClassicCollection: true, 9627 guid: collectionKey, 9628 metadata: processedImages, 9629 totalItems: processedImages.length 9630 } 9631 }; 9632 console.log('Created jsonCollections for import_ gallery (no galleryOptions):', title, '(' + processedImages.length + ' images)'); 9633 try { 9634 const ndjsonContent = buildHydraClassicNdjson(itemGuid, processedImages); 9635 const uploadResult = await uploadNdjsonToTigris(currentSiteId, galleryPageId, ndjsonContent); 9636 if (uploadResult && uploadResult.success) { 9637 const hydraCollectionKey = 'GUID=' + currentSiteId; 9638 enhancedGalleryOptions.manualCollectionName = hydraCollectionKey; 9639 enhancedGalleryOptions.jsonCollections = { 9640 [hydraCollectionKey]: { 9641 isClassicCollection: false, 9642 guid: hydraCollectionKey, 9643 metadata: {}, 9644 totalItems: processedImages.length 9645 } 9646 }; 9647 enhancedGalleryOptions.siteAlias = window.Parameters?.SiteAlias || window.location?.hostname || currentSiteId; 9648 enhancedGalleryOptions.initialPageUuid = galleryPageId; 9649 enhancedGalleryOptions.isHydra = true; 9650 enhancedGalleryOptions.flyIoSaveTimestamp = Date.now(); 9651 if (uploadResult.irysResult?.txId) { 9652 enhancedGalleryOptions.txId = uploadResult.irysResult.txId; 9653 enhancedGalleryOptions.permaURL = uploadResult.irysResult.txId; 9654 enhancedGalleryOptions.isPerma = true; 9655 enhancedGalleryOptions.uploadedTo = uploadResult.uploadedTo || ['fly']; 9656 } 9657 console.log('NDJSON uploaded for import_ gallery "' + title + '" (no galleryOptions) - configured as Hydra NDJSON'); 9658 } 9659 } catch (e) { 9660 console.warn('NDJSON build/upload skipped for import_ gallery "' + title + '":', e); 9661 } 9662 } 9663 9664 // Note: For import from pasted JSON, we don't include image data to avoid overwriting existing captions 9665 // Image data is only included when importing from Classic GUID directly 9666 console.log(`Gallery ${title} imported with structure and settings preserved (image data not included to avoid overwriting existing captions)`); 9667 9668 newItem = { 9669 id: newItemId, title, url: `/${finalSlug}`, isIntegrated: true, isPage: false, isSubmenu: false, visible: classicItem.visible !== false, parentId: null, 9670 siteId: currentSiteId, pageId: galleryPageId, classicCategoryId: classicItem.categoryID, classicGuid: itemGuid, 9671 importSource: 'classic-gallery', slug: finalSlug, normalizedName: finalSlug, 9672 galleryOptions: enhancedGalleryOptions 9673 }; 9674 } 9675 9676 tempNewItems.push(newItem); 9677 } 9678 9679 // --- Pass 2: Link children to parents using the ID map ---
9680 tempNewItems.forEach(newItem => { 9681 const originalId = Object.keys(originalIdToNewIdMap).find(key => originalIdToNewIdMap[key] === newItem.id); 9682 const originalItem = galleriesForImport.find(g => String(g.id) === String(originalId)); 9683 9684 if (originalItem && originalItem.parentId) { 9685 const newParentId = originalIdToNewIdMap[originalItem.parentId]; 9686 if (newParentId) { 9687 newItem.parentId = newParentId; 9688 const parentItem = tempNewItems.find(p => p.id === newParentId); 9689 if (parentItem) { 9690 parentItem.isSubmenu = true; 9691 parentItem.isCollapsed = true; 9692 } 9693 } else { 9694 newItem.parentId = submenuParentId; 9695 } 9696 } else { 9697 newItem.parentId = submenuParentId; 9698 } 9699 }); 9700 9701 if (replaceMenu) { 9702 console.log("Replacing existing menu structure."); 9703 // If replacing, overwrite the entire galleries array. 9704 galleries = tempNewItems; 9705 } else { 9706 console.log("Appending to existing menu structure."); 9707 // If not replacing, push the new items to the existing array. 9708 galleries.push(...tempNewItems); 9709 } 9710 9711 9712 9713 if (progressBar) progressBar.style.width = '90%'; 9714 if (statusMessage) statusMessage.textContent = 'Saving changes...'; 9715 9716 // Pass import flag to skip localStorage for large import operations 9717 await saveGalleries({ isImport: true }); 9718 9719 if (progressBar) progressBar.style.width = '100%'; 9720 if (statusMessage) statusMessage.textContent = `Successfully imported ${tempNewItems.length} items!`; 9721 if (statusEl) statusEl.classList.remove('importing'); 9722 9723 setTimeout(() => { 9724 renderGalleries(); 9725 toggleImportClassicForm(); 9726 if (progressBar) progressBar.style.width = '0'; 9727 if (statusEl) statusEl.style.display = 'none'; 9728 if (confirm('Import complete! Would you like to reload the page to ensure everything is displayed correctly?')) { 9729 window.location.reload(); 9730 } 9731 }, 1000); 9732 9733 } catch (error) { 9734 console.error('Error importing classic galleries:', error); 9735 if (statusMessage) statusMessage.textContent = `Error: ${error.message}`; 9736 if (progressBar) { 9737 progressBar.style.width = '100%'; 9738 progressBar.style.backgroundColor = '#dc3545'; 9739 } 9740 if (statusEl) statusEl.classList.remove('importing'); 9741 } 9742} 9743 9744 9745 9746/** 9747 * Improved toggleImportClassicForm - Closes other forms first 9748 */ 9749function toggleImportClassicForm() { 9750 // First check if user is logged in and in edit mode 9751 if (!isAuthenticated || !isAdmin || !window.isInEditMode()) { 9752 alert('You must be logged in as an admin and in edit mode to import galleries.'); 9753 return; 9754 } 9755 9756 const formId = 'importClassicForm'; 9757 const form = document.getElementById(formId); 9758 9759 if (!form) { 9760 console.error('Import form element not found'); 9761 return; 9762 } 9763 9764 // If this form is already open, just close everything 9765 if (window.currentOpenForm === formId) { 9766 closeAllForms(); 9767 return; 9768 } 9769 9770 // Otherwise close all forms and open this one 9771 closeAllForms(); 9772 9773 // Show the form 9774 form.style.display = 'block'; 9775 9776 // Use setTimeout to allow the display change to take effect before adding the class 9777 setTimeout(() => { 9778 form.classList.add('visible'); 9779 }, 10); 9780 9781 // Populate parent options 9782 const parentSelect = document.getElementById('importParent'); 9783 if (parentSelect) { 9784 parentSelect.innerHTML = '<option value="">Top Level (No Parent)</option>'; 9785 9786 // Add all galleries that could be parents (including submenus) 9787 galleries.forEach(gallery => { 9788 // Only include items that are not already marked as imported 9789 if (!gallery.importSource) { 9790 parentSelect.innerHTML += `<option value="${gallery.id}">${gallery.title}</option>`; 9791 } 9792 }); 9793 } 9794 9795 // Set default values 9796 const submenuTitle = document.getElementById('submenuTitle'); 9797 if (submenuTitle && !submenuTitle.value) { 9798 submenuTitle.value = 'Classic Galleries'; 9799 } 9800 9801 // Reset any previous import status 9802 const statusEl = document.getElementById('importStatus'); 9803 if (statusEl) { 9804 statusEl.style.display = 'none'; 9805 const progressBar = statusEl.querySelector('.progress-bar'); 9806 if (progressBar) { 9807 progressBar.style.width = '0'; 9808 progressBar.style.backgroundColor = '#4682B4'; // Reset to blue 9809 } 9810 } 9811 9812 // Set this as the current open form 9813 window.currentOpenForm = formId; 9814} 9815 9816// Add event listener for the radio buttons to adjust height when page is selected 9817document.addEventListener('DOMContentLoaded', function() { 9818 const galleryTypeRadios = document.querySelectorAll('input[name="galleryType"]'); 9819 galleryTypeRadios.forEach(radio => { 9820 radio.addEventListener('change', function() { 9821 const addForm = document.getElementById('addForm'); 9822 if (addForm && addForm.classList.contains('visible')) { 9823 const isPageSelected = this.value === 'page'; 9824 9825 // Give more height for page type 9826 if (isPageSelected) { 9827 addForm.style.maxHeight = '600px'; 9828 } else { 9829 // For other types, measure content height 9830 const contentHeight = addForm.scrollHeight + 30; 9831 addForm.style.maxHeight = Math.max(contentHeight, 500) + 'px'; 9832 } 9833 } 9834 }); 9835 }); 9836}); 9837 9838 9839/** 9840 * Improved toggleStyleEditor - Closes other forms first 9841 */ 9842function toggleStyleEditor(display = false, event) { 9843 const formId = 'menuStyleEditor'; 9844 const overlayOpen = window.MenuStyleCustomizer && window.MenuStyleCustomizer._overlayOpen; 9845 9846 if (overlayOpen && window.MenuStyleCustomizer._overlayMinimized && display !== false) { 9847 if (typeof window.MenuStyleCustomizer._toggleOverlayMinimized === 'function') { 9848 window.MenuStyleCustomizer._toggleOverlayMinimized(false); 9849 } 9850 return; 9851 } 9852 9853 if (display === false || window.currentOpenForm === formId || overlayOpen) { 9854 closeAllForms(); 9855 return; 9856 } 9857 9858 closeAllForms(); 9859 9860 if (window.MenuStyleCustomizer && typeof window.MenuStyleCustomizer.toggleOverlay === 'function') { 9861 window.MenuStyleCustomizer.toggleOverlay(); 9862 } 9863} 9864 9865 9866/** 9867 * This function closes all forms when escape key is pressed 9868 */ 9869document.addEventListener('keydown', function(e) { 9870 if (e.key === 'Escape' && window.currentOpenForm) { 9871 if (closeTopmostStyleCustomizerChildOverlay()) { 9872 return; 9873 } 9874 if (window.MenuStyleCustomizer && 9875 window.MenuStyleCustomizer._overlayOpen && 9876 window.MenuStyleCustomizer._overlayMinimized) { 9877 return; 9878 } 9879 closeAllForms(); 9880 } 9881}); 9882 9883/** 9884 * This function closes all forms when clicking outside any form 9885 */ 9886document.addEventListener('click', function(e) { 9887 if (!window.currentOpenForm) return; 9888 9889 if (window.MenuStyleCustomizer && 9890 window.MenuStyleCustomizer._overlayOpen && 9891 window.MenuStyleCustomizer._overlayMinimized) { 9892 return; 9893 } 9894 9895 // Keep Style Customizer and its nested panels open during in-overlay interactions. 9896 if (e.target.closest( 9897 '.pe-style-customizer-overlay, .pe-custom-code-overlay, .pe-palette-explorer-overlay, .pe-font-explorer-overlay, .pe-style-history-overlay, .sc-style-history-overlay, .pe-unsaved-changes-overlay, #colorMixerPopup, #artPaletteBtn, #exploreFontsBtn, #customCodeBtn, #colorMixerDot' 9898 )) { 9899 return; 9900 } 9901 9902 if (e.target.closest('.image-uploader-overlay')) { 9903 return; 9904 } 9905 9906 const currentForm = document.getElementById(window.currentOpenForm); 9907 if (!currentForm) { 9908 if (e.target.closest( 9909 '.pe-style-customizer-overlay, .pe-custom-code-overlay, .pe-palette-explorer-overlay, .pe-font-explorer-overlay, .pe-style-history-overlay, .sc-style-history-overlay, .pe-unsaved-changes-overlay, #colorMixerPopup' 9910 )) { 9911 return; 9912 } 9913 closeAllForms(); 9914 return; 9915 } 9916 9917 const isInsideForm = currentForm.contains(e.target);
9918 const isFormToggleButton = e.target.closest('[onclick*="toggle"]') !== null; 9919 9920 if (isInsideForm || isFormToggleButton) { 9921 return; 9922 } 9923 9924 closeAllForms(); 9925}); 9926 9927// Override the original functions with our improved versions 9928window.toggleAddForm = toggleAddForm; 9929window.toggleStyleEditor = toggleStyleEditor; 9930window.toggleMetadataEditor = toggleMetadataEditor; 9931window.toggleSidebarElementForm = toggleSidebarElementForm; 9932window.toggleImportClassicForm = toggleImportClassicForm; 9933window.closeAllForms = closeAllForms; 9934window.closeTopmostStyleCustomizerChildOverlay = closeTopmostStyleCustomizerChildOverlay; 9935 9936 9937/** 9938 * Opens the edit form for a gallery item. 9939 * (UPDATED to include Meta Title and Meta Description fields, removed Parent Menu) 9940 * @param {number} id - The ID of the gallery item. 9941 * @param {Event} event - The click event. 9942 */ 9943function editGallery(id, event) { 9944 event.stopPropagation(); // Prevent event bubbling 9945 const gallery = findGalleryById(galleries, id); 9946 const li = getGalleryTreeListItem(id, event); 9947 9948 if (gallery && li) { 9949 const showUrlField = gallery.isPage || gallery.isIntegrated; 9950 const showExternalUrlField = gallery.isExternal; 9951 const showMenuParentOption = !gallery.isFolder && !gallery.isSubmenu && !gallery.isSpacer && !gallery.isExternal && 9952 (isPageMenuItem(gallery) || gallery.isIntegrated === true); 9953 /** Sidebar meta title/description apply to gallery routes only; pages use the page metadata element. */ 9954 const showSidebarSeoFields = !gallery.isPage; 9955 const currentSlug = gallery.slug || (gallery.url && gallery.url.startsWith('/') ? gallery.url.substring(1) : ''); 9956 const currentExternalUrl = gallery.url || ''; 9957 9958 // Clear existing content and rebuild form to ensure event listeners are fresh if needed 9959 li.innerHTML = ''; // Clear previous form if any 9960 9961 const formDiv = document.createElement('div'); 9962 formDiv.className = 'edit-form'; 9963 formDiv.innerHTML = ` 9964 <div class="form-group"> 9965 <label>Title:</label> 9966 <input type="text" value="${gallery.title || ''}" class="edit-title"> 9967 </div> 9968 9969 ${showUrlField ? ` 9970 <div class="form-group"> 9971 <label>URL Slug:</label> 9972 <input type="text" value="${currentSlug}" class="edit-slug" placeholder="e.g., my-gallery"> 9973 <small>Path after domain name (e.g., yoursite.com<b>/my-gallery</b>). Use lowercase letters, numbers, and hyphens.</small> 9974 </div> 9975 ` : ''} 9976 9977 ${showExternalUrlField ? ` 9978 <div class="form-group"> 9979 <label>External URL:</label> 9980 <input type="text" value="${currentExternalUrl}" class="edit-external-url" placeholder="https://example.com"> 9981 <small>Full URL for the external link (e.g., https://example.com/page).</small> 9982 </div> 9983 ` : ''} 9984 9985 ${showSidebarSeoFields ? ` 9986 <div class="form-group"> 9987 <label>Meta Title (Optional):</label> 9988 <input type="text" value="${gallery.metaTitle || ''}" class="edit-meta-title" placeholder="Title for search results (if different from main title)"> 9989 <small>Overrides site title for this item in search results/browser tabs.</small> 9990 </div> 9991 9992 <div class="form-group"> 9993 <label>Meta Description (Optional):</label> 9994 <textarea class="edit-meta-description" placeholder="Description for search results (150-160 chars recommended)">${gallery.metaDescription || ''}</textarea> 9995 <small>Overrides site description for this item in search results.</small> 9996 </div> 9997 ` : ` 9998 <div class="form-group"> 9999 <p class="text-muted" style="margin: 0 0 8px 0; font-size: 12px; line-height: 1.4;"> 10000 Search title and description for this page are set in <strong>Page â metadata</strong> (the metadata block on the page), not here. 10001 </p> 10002 </div> 10003 `} 10004 10005 <div class="form-group"> 10006 <label style="display: flex; align-items: center; gap: 8px;"> 10007 <input type="checkbox" class="edit-hide-menu" ${gallery.hideMenuOnPage ? 'checked' : ''} style="width: auto; margin-bottom: 0;" onchange="onPageMenuChromeToggle(this)"> 10008 Hide menu when viewing this page 10009 </label> 10010 <small>Allows for a full-screen page or gallery layout. Menu remains visible in edit mode.</small> 10011 </div> 10012 10013 <div class="form-group"> 10014 <label style="display: flex; align-items: center; gap: 8px;"> 10015 <input type="checkbox" class="edit-overlay-menu" ${gallery.overlayMenuOnPage ? 'checked' : ''} style="width: auto; margin-bottom: 0;" onchange="onPageMenuChromeToggle(this)"> 10016 Show menu as overlay on this page 10017 </label> 10018 <small>Keeps menu items visible over a full-bleed page. Menu background is hidden. Solid menu remains in edit mode.</small> 10019 </div> 10020 10021 ${showMenuParentOption ? ` 10022 <div class="form-group"> 10023 <label style="display: flex; align-items: center; gap: 8px;"> 10024 <input type="checkbox" class="edit-acts-as-menu-parent" ${gallery.actsAsMenuParent === true ? 'checked' : ''} style="width: auto; margin-bottom: 0;"> 10025 Allow nested menu items 10026 </label> 10027 <small>Keeps this ${isPageMenuItem(gallery) ? 'page' : 'gallery'} clickable while allowing it to expand and contain sub-items.</small> 10028 </div> 10029 ` : ''} 10030 10031 <div class="form-group"> 10032 <label style="display: flex; align-items: center; gap: 8px;"> 10033 <input type="checkbox" class="edit-password-protected" ${gallery.passwordProtected ? 'checked' : ''} style="width: auto; margin-bottom: 0;"> 10034 Password protect this page 10035 </label> 10036 <small>Visitors will need to enter a password to access this page.</small> 10037 </div> 10038 10039 <div class="form-group password-field" style="display: ${gallery.passwordProtected ? 'block' : 'none'};"> 10040 <label>Password:</label> 10041 <input type="password" value="${gallery.password || ''}" class="edit-password" placeholder="Enter password"> 10042 <small>Password visitors must enter to access this page.</small> 10043 </div> 10044 10045 <div class="form-group"> 10046 <label style="display: flex; align-items: center; gap: 8px;"> 10047 <input type="checkbox" class="edit-no-index" ${(() => { 10048 // Explicit setting takes priority;
10048 otherwise default to checked for hidden non-home pages 10049 if (gallery.noIndex === true) return 'checked'; 10050 if (gallery.noIndex === false) return ''; 10051 return (gallery.visible === false && !gallery.isHomePage) ? 'checked' : ''; 10052 })()} style="width: auto; margin-bottom: 0;"> 10053 Hide from search engines (noindex) 10054 </label> 10055 <small>When checked, search engines will not index this page. Automatically enabled for hidden pages â uncheck to allow indexing even when hidden.</small> 10056 </div> 10057 10058 <div class="edit-form-actions"> 10059 <button class="btn btn-primary" id="saveEditBtn-${id}">Save</button> 10060 <button class="btn" id="cancelEditBtn-${id}">Cancel</button> 10061 </div> 10062 <br> 10063 `; 10064 li.appendChild(formDiv); // Append the new form 10065 10066 // Add event listeners programmatically 10067 const saveButton = formDiv.querySelector(`#saveEditBtn-${id}`); 10068 if (saveButton) { 10069 saveButton.addEventListener('click', (e) => saveEdit(id, e)); 10070 } 10071 10072 const cancelButton = formDiv.querySelector(`#cancelEditBtn-${id}`); 10073 if (cancelButton) { 10074 cancelButton.addEventListener('click', () => renderGalleries()); 10075 } 10076 10077 // Add password protection toggle functionality 10078 const passwordProtectedCheckbox = formDiv.querySelector('.edit-password-protected'); 10079 const passwordField = formDiv.querySelector('.password-field'); 10080 if (passwordProtectedCheckbox && passwordField) { 10081 passwordProtectedCheckbox.addEventListener('change', function() { 10082 passwordField.style.display = this.checked ? 'block' : 'none'; 10083 }); 10084 } 10085 10086 li.classList.add('edit-mode'); 10087 } else { 10088 console.error("Could not find gallery or list item for ID:", id); 10089 } 10090} 10091 10092/** 10093 * Resolve the correct sidebar #galleryTree li for an id. Horizontal dropdowns also use 10094 * li[data-id] (.horizontal-submenu-item); document.querySelector would return that node first 10095 * and break nested folder toggles in edit mode while horizontal layout is on. 10096 * @param {number} id - Gallery ID 10097 * @param {Event|null} event - Optional click event (preferred: closest li inside #galleryTree) 10098 * @returns {HTMLElement|null} 10099 */ 10100function getGalleryTreeListItem(id, event) { 10101 var numericId = typeof id === 'number' ? id : parseInt(String(id), 10); 10102 if (isNaN(numericId)) return null; 10103 var galleryTreeEl = document.getElementById('galleryTree'); 10104 if (event && event.target && galleryTreeEl) { 10105 var clicked = event.target.closest('li[data-id]'); 10106 if (clicked && galleryTreeEl.contains(clicked)) { 10107 var cid = parseInt(clicked.getAttribute('data-id') || '', 10); 10108 if (cid === numericId) return clicked; 10109 } 10110 } 10111 if (galleryTreeEl) { 10112 var scoped = galleryTreeEl.querySelector('li[data-id="' + numericId + '"]'); 10113 if (scoped) return scoped; 10114 } 10115 return document.querySelector('li[data-id="' + numericId + '"]'); 10116} 10117 10118/** 10119 * Handles click on a folder item to show/hide its contents 10120 * @param {number} id - The gallery ID 10121 * @param {Event} event - The click event 10122 */ 10123function toggleFolder(id, event) { 10124 // Ensure the event doesn't interfere with drag operations 10125 if (event) { 10126 event.stopPropagation(); 10127 } 10128 10129 10130 const gallery = galleries.find(g => g.id === id); 10131 if (!gallery) { 10132 return; 10133 } 10134 10135 if (gallery.isFolder !== true && gallery.isSubmenu !== true && !isNavigableMenuParent(gallery)) { 10136 return; 10137 } 10138 10139 10140 // Toggle the collapsed state 10141 gallery.isCollapsed = !gallery.isCollapsed; 10142 10143 // Find the list item in the DOM (#galleryTree first â not horizontal li.horizontal-submenu-item) 10144 const listItem = getGalleryTreeListItem(id, event); 10145 if (!listItem) { 10146 return; 10147 } 10148 10149 10150 // Toggle the expanded class FIRST - this is important for CSS to work correctly 10151 listItem.classList.toggle('expanded', !gallery.isCollapsed); 10152 10153 // Keep ARIA in sync for folder label + chevron (screen readers / keyboard) 10154 const folderExpanded = !gallery.isCollapsed; 10155 listItem.querySelectorAll('.menu-item.folder, .submenu-toggle').forEach((el) => { 10156 el.setAttribute('aria-expanded', folderExpanded ? 'true' : 'false'); 10157 }); 10158 10159 // Force a reflow after toggling the class to ensure CSS recalculates 10160 void listItem.offsetHeight; 10161 10162 // Find the nested list - SIMPLIFIED: Check if we're in sidebar first, then horizontal 10163 // CRITICAL: Check #galleryTree first - this is the definitive indicator of sidebar layout 10164 // Use contains() instead of closest() because closest() only checks ancestors 10165 const galleryTree = document.getElementById('galleryTree'); 10166 const isInGalleryTree = galleryTree && galleryTree.contains(listItem); 10167 const isInSidebar = listItem.closest('.sidebar') || listItem.closest('#sidebar') || isInGalleryTree; 10168 // Only consider it horizontal menu if it's explicitly in .horizontal-menu
10168-container AND not in galleryTree 10169 const isInHorizontalMenu = listItem.closest('.horizontal-menu-container') && !isInGalleryTree && !isInSidebar; 10170 10171 let nestedList = null; 10172 10173 // If we're in sidebar (#galleryTree), ONLY look for ol.nested-sortable - ignore horizontal structures 10174 // CRITICAL: Prioritize sidebar detection - if we're in galleryTree, we're definitely in sidebar 10175 if (isInGalleryTree) { 10176 nestedList = listItem.querySelector('ol.nested-sortable'); 10177 10178 // Also check direct children as fallback 10179 if (!nestedList) { 10180 const directChildren = Array.from(listItem.children); 10181 nestedList = directChildren.find(child => child.tagName === 'OL' && child.classList.contains('nested-sortable')); 10182 } 10183 } else if (isInSidebar) { 10184 // Fallback: if we're in .sidebar but not in #galleryTree, still treat as sidebar 10185 nestedList = listItem.querySelector('ol.nested-sortable'); 10186 if (!nestedList) { 10187 const directChildren = Array.from(listItem.children); 10188 nestedList = directChildren.find(child => child.tagName === 'OL' && child.classList.contains('nested-sortable')); 10189 } 10190 } 10191 10192 // For horizontal layout, check horizontal structures (ONLY if NOT in sidebar/galleryTree) 10193 if (!nestedList && isInHorizontalMenu && !isInGalleryTree && !isInSidebar) { 10194 nestedList = listItem.querySelector('.horizontal-submenu'); 10195 if (!nestedList) { 10196 nestedList = listItem.querySelector('.horizontal-dropdown'); 10197 } 10198 if (!nestedList) { 10199 // For horizontal nested dropdowns, they're appended to document.body, not the list item 10200 // They're linked via data-flyout-id attribute 10201 const flyoutId = listItem.getAttribute('data-flyout-id'); 10202 if (flyoutId) { 10203 // Look specifically for div.horizontal-nested-dropdown, not the list item itself 10204 const allElementsWithId = document.querySelectorAll('[data-flyout-id="' + flyoutId + '"]'); 10205 10206 // Find the actual dropdown div, not the list item 10207 const flyout = Array.from(allElementsWithId).find(el => 10208 el.classList.contains('horizontal-nested-dropdown') && el !== listItem 10209 ); 10210 10211 10212 if (flyout) { 10213 nestedList = flyout.querySelector('.horizontal-nested-list') || flyout; 10214 } 10215 } 10216 } 10217 if (!nestedList) { 10218 nestedList = listItem.querySelector('.horizontal-nested-dropdown'); 10219 } 10220 } 10221 10222 // Fallback: try all selectors if not found yet 10223 if (!nestedList) { 10224 nestedList = listItem.querySelector('ol.nested-sortable'); 10225 } 10226 if (!nestedList) { 10227 // Debug: Log the actual DOM structure to understand what's there 10228 10229 // Try to find any nested structure at all 10230 const anyNested = listItem.querySelector('ol, ul, div[class*="submenu"], div[class*="nested"], div[class*="dropdown"]'); 10231 if (anyNested) { 10232 nestedList = anyNested; // Use it anyway 10233 } 10234 10235 // Last resort: Check if nested list exists as a direct child but wasn't found by querySelector 10236 // This can happen if the element was removed/re-added or if there's a timing issue 10237 const directChildren = Array.from(listItem.children); 10238 const olChild = directChildren.find(child => child.tagName === 'OL'); 10239 if (olChild) { 10240 nestedList = olChild; 10241 } 10242 } 10243 10244 if (!nestedList) { 10245 10246 // If the nested list doesn't exist, we need to create it 10247 // This can happen if children aren't rendered until the folder is opened 10248 // First try flat array 10249 let gallery = galleries.find(g => g.id === id); 10250 10251 // If gallery not found or has no children in flat array, build tree structure to find it 10252 if (!gallery || !gallery.children || gallery.children.length === 0) { 10253 10254 // Build tree structure from flat array 10255 const buildTree = (items, parentId = null) => { 10256 return items 10257 .filter(item => item.parentId === parentId) 10258 .map(item => ({ 10259 ...item, 10260 children: buildTree(items, item.id) 10261 })); 10262 }; 10263 10264 const galleryTree = buildTree(galleries); 10265 10266 // Recursive function to find gallery in tree 10267 const findInTree = (items) => { 10268 for (const item of items) { 10269 if (item.id === id) { 10270 return item; 10271 } 10272 if (item.children && item.children.length > 0) { 10273 const found = findInTree(item.children); 10274 if (found) return found; 10275 } 10276 } 10277 return null; 10278 }; 10279 10280 const treeGallery = findInTree(galleryTree); 10281 if (treeGallery) { 10282 gallery = treeGallery; 10283 } 10284 } 10285 10286 if (gallery && gallery.children && gallery.children.length > 0) { 10287 10288 // Determine nesting level from list item 10289 const currentLevel = parseInt(listItem.getAttribute('data-nesting-level') || '0', 10); 10290 const nextLevel = currentLevel + 1; 10291 10292 // Create the nested list element 10293 nestedList = document.createElement('ol'); 10294 nestedList.id = 'gallery-children-' + id; 10295 nestedList.className = 'nested-sortable'; 10296 if (nextLevel > 0) { 10297 nestedList.classList.add('nested-level-' + nextLevel); 10298 } 10299 10300 // Render the children - ensure we're in the right context (not in a horizontal dropdown wrapper)
10301 gallery.children.forEach(child => { 10302 const childHtml = renderGalleryItem(child, nextLevel, 20); 10303 const tempDiv = document.createElement('div'); 10304 tempDiv.innerHTML = childHtml; 10305 const childLi = tempDiv.querySelector('li'); 10306 if (childLi) { 10307 nestedList.appendChild(childLi); 10308 } 10309 }); 10310 10311 // CRITICAL: Only check for horizontal dropdowns if we're actually in a horizontal menu context 10312 // If we're in #galleryTree (sidebar), skip all horizontal menu logic 10313 const galleryTree = document.getElementById('galleryTree'); 10314 const isInGalleryTree = galleryTree && galleryTree.contains(listItem); 10315 const isInSidebar = listItem.closest('.sidebar') || listItem.closest('#sidebar') || isInGalleryTree; 10316 10317 if (!isInGalleryTree && !isInSidebar) { 10318 const isInHorizontalMenu = listItem.closest('.horizontal-menu-container'); 10319 if (isInHorizontalMenu) { 10320 const horizontalDropdown = listItem.closest('.horizontal-dropdown, .horizontal-nested-dropdown'); 10321 if (horizontalDropdown && horizontalDropdown !== listItem) { 10322 horizontalDropdown.style.setProperty('display', 'block', 'important'); 10323 horizontalDropdown.style.setProperty('visibility', 'visible', 'important'); 10324 horizontalDropdown.style.setProperty('opacity', '1', 'important'); 10325 } 10326 } 10327 } 10328 10329 // CRITICAL: Ensure list item itself is visible and has correct display 10330 listItem.style.setProperty('display', 'list-item', 'important'); 10331 listItem.style.setProperty('visibility', 'visible', 'important'); 10332 10333 // CRITICAL: Ensure ALL parent elements up to the sidebar are visible 10334 // BUT: Don't make .horizontal-menu-container visible - that's the entire horizontal menu! 10335 let parent = listItem.parentElement; 10336 let depth = 0; 10337 while (parent && depth < 10) { 10338 // Skip .horizontal-menu-container - we don't want to show the entire horizontal menu 10339 if (parent.classList.contains('horizontal-menu-container')) { 10340 parent = parent.parentElement; 10341 depth++; 10342 continue; 10343 } 10344 10345 const parentStyles = window.getComputedStyle(parent); 10346 if (parentStyles.display === 'none' || parentStyles.visibility === 'hidden' || parentStyles.opacity === '0') { 10347 if (parentStyles.display === 'none') { 10348 parent.style.setProperty('display', 'block', 'important'); 10349 } 10350 if (parentStyles.visibility === 'hidden') { 10351 parent.style.setProperty('visibility', 'visible', 'important'); 10352 } 10353 if (parentStyles.opacity === '0') { 10354 parent.style.setProperty('opacity', '1', 'important'); 10355 } 10356 } 10357 // Stop at sidebar or galleryTree 10358 if (parent.classList.contains('sidebar') || parent.id === 'galleryTree' || parent.id === 'sidebar') { 10359 break; 10360 } 10361 parent = parent.parentElement; 10362 depth++; 10363 } 10364 10365 // Append directly to the list item (not to any wrapper) 10366 // Make sure we're not appending to a hidden parent 10367 listItem.appendChild(nestedList); 10368 10369 // Immediately set the nested list to be visible since we're opening it 10370 nestedList.style.setProperty('display', 'block', 'important'); 10371 nestedList.style.setProperty('visibility', 'visible', 'important'); 10372 nestedList.style.setProperty('opacity', '1', 'important'); 10373 nestedList.style.setProperty('height', 'auto', 'important'); 10374 nestedList.style.setProperty('max-height', 'none', 'important'); 10375 10376 // Force immediate reflow after appending 10377 void nestedList.offsetHeight; 10378 void listItem.offsetHeight; 10379 } 10380 } 10381 10382 if (nestedList) { 10383 if (!gallery.isCollapsed) { 10384 // Show the nested list - ensure parent has expanded class FIRST 10385 // CRITICAL: Add expanded class BEFORE any style operations 10386 listItem.classList.add('expanded'); 10387 10388 // Verify expanded class was added 10389 if (!listItem.classList.contains('expanded')) { 10390 listItem.className = listItem.className + ' expanded'; 10391 } 10392 10393 // CRITICAL: Ensure parent li is visible (it might be hidden by CSS) 10394 const parentVisibility = window.getComputedStyle(listItem).visibility; 10395 if (parentVisibility === 'hidden') { 10396 listItem.style.setProperty('visibility', 'visible', 'important'); 10397 } 10398 10399 // Force a reflow to ensure CSS recalculates after adding expanded class 10400 void listItem.offsetHeight; 10401 void nestedList.offsetHeight; 10402 10403 // Show the nested list with !important to override CSS 10404 // Set these BEFORE removing any existing inline styles to avoid flicker 10405 nestedList.style.setProperty('display', 'block', 'important'); 10406 nestedList.style.setProperty('visibility', 'visible', 'important'); 10407 nestedList.style.setProperty('opacity', '1', 'important'); 10408 nestedList.style.setProperty('height', 'auto', 'important'); 10409 nestedList.style.setProperty('max-height', 'none', 'important'); 10410 nestedList.style.setProperty('overflow', 'visible', 'important'); 10411 10412 // Also ensure the nested list is not clipped by parent overflow
10413 // BUT: Don't modify .horizontal-menu-container overflow - that's the entire horizontal menu! 10414 let checkParent = nestedList.parentElement; 10415 let checkDepth = 0; 10416 while (checkParent && checkDepth < 5) { 10417 // Skip .horizontal-menu-container - we don't want to modify the entire horizontal menu 10418 if (checkParent.classList.contains('horizontal-menu-container')) { 10419 checkParent = checkParent.parentElement; 10420 checkDepth++; 10421 continue; 10422 } 10423 10424 const parentOverflow = window.getComputedStyle(checkParent).overflow; 10425 if (parentOverflow === 'hidden') { 10426 checkParent.style.setProperty('overflow', 'visible', 'important'); 10427 } 10428 if (checkParent.classList.contains('sidebar') || checkParent.id === 'galleryTree') { 10429 break; 10430 } 10431 checkParent = checkParent.parentElement; 10432 checkDepth++; 10433 } 10434 10435 // Force another reflow after setting styles 10436 void nestedList.offsetHeight; 10437 10438 const finalRect = nestedList.getBoundingClientRect(); 10439 10440 // If the nested list has zero dimensions, it's not actually visible 10441 if (finalRect.width === 0 || finalRect.height === 0) { 10442 // Force all parent elements to be visible 10443 // BUT: Don't modify .horizontal-menu-container - that's the entire horizontal menu! 10444 let forceParent = nestedList.parentElement; 10445 while (forceParent && forceParent !== document.body) { 10446 // Skip .horizontal-menu-container - we don't want to show the entire horizontal menu 10447 if (forceParent.classList.contains('horizontal-menu-container')) { 10448 forceParent = forceParent.parentElement; 10449 continue; 10450 } 10451 10452 forceParent.style.setProperty('display', 'block', 'important'); 10453 forceParent.style.setProperty('visibility', 'visible', 'important'); 10454 forceParent.style.setProperty('opacity', '1', 'important'); 10455 forceParent.style.setProperty('overflow', 'visible', 'important'); 10456 if (forceParent.classList.contains('sidebar') || forceParent.id === 'galleryTree') { 10457 break; 10458 } 10459 forceParent = forceParent.parentElement; 10460 } 10461 void nestedList.offsetHeight; 10462 } 10463 10464 // Check for any parent elements that might be hiding the nested list and fix them 10465 let parent = nestedList.parentElement; 10466 let depth = 0; 10467 while (parent && depth < 5) { 10468 const parentStyles = window.getComputedStyle(parent); 10469 if (parentStyles.display === 'none' || parentStyles.visibility === 'hidden' || parentStyles.opacity === '0') { 10470 10471 // Fix hidden parent elements 10472 if (parentStyles.display === 'none') { 10473 parent.style.setProperty('display', 'block', 'important'); 10474 } 10475 if (parentStyles.visibility === 'hidden') { 10476 parent.style.setProperty('visibility', 'visible', 'important'); 10477 } 10478 if (parentStyles.opacity === '0') { 10479 parent.style.setProperty('opacity', '1', 'important'); 10480 } 10481 } 10482 parent = parent.parentElement; 10483 depth++; 10484 } 10485 10486 } else { 10487 // Hide the nested list 10488 nestedList.style.setProperty('display', 'none', 'important'); 10489 nestedList.style.setProperty('visibility', 'hidden', 'important'); 10490 nestedList.style.setProperty('opacity', '0', 'important'); 10491 } 10492 10493 // Force multiple reflows to ensure the browser applies all changes 10494 void listItem.offsetHeight; 10495 void nestedList.offsetHeight; 10496 void listItem.offsetWidth; 10497 void nestedList.offsetWidth; 10498 10499 // Force a more aggressive repaint by temporarily toggling a class 10500 // This is especially important for sidebar layout where CSS rules might be interfer
10500ing 10501 listItem.classList.add('force-reflow'); 10502 void listItem.offsetHeight; 10503 listItem.classList.remove('force-reflow'); 10504 10505 // Force reflow again after class manipulation 10506 void listItem.offsetHeight; 10507 void nestedList.offsetHeight; 10508 10509 // Use requestAnimationFrame with multiple frames to ensure visual update 10510 // This ensures the browser has time to recalculate layout and repaint 10511 requestAnimationFrame(() => { 10512 void nestedList.offsetHeight; 10513 // Second frame to ensure all CSS has been recalculated 10514 requestAnimationFrame(() => { 10515 void nestedList.offsetHeight; 10516 // Verify the final state 10517 const finalDisplay = window.getComputedStyle(nestedList).display; 10518 const finalVisibility = window.getComputedStyle(nestedList).visibility; 10519 10520 // If still not visible, force one more repaint cycle 10521 if (!gallery.isCollapsed && finalDisplay === 'none') { 10522 nestedList.style.setProperty('display', 'block', 'important'); 10523 nestedList.style.setProperty('visibility', 'visible', 'important'); 10524 void nestedList.offsetHeight; 10525 requestAnimationFrame(() => { 10526 void nestedList.offsetHeight; 10527 }); 10528 } 10529 }); 10530 }); 10531 } 10532 10533 // Find the submenu toggle icon and rotate it 10534 const toggleIcon = listItem.querySelector('.toggle-icon'); 10535 if (toggleIcon) { 10536 toggleIcon.classList.toggle('rotated', !gallery.isCollapsed); 10537 } 10538 10539 10540 // Folder open/closed is a local UI preference â do not POST the full site config. 10541 try { 10542 const folderStates = JSON.parse(localStorage.getItem('folder_states') || '{}'); 10543 folderStates[id] = !gallery.isCollapsed; 10544 localStorage.setItem('folder_states', JSON.stringify(folderStates)); 10545 } catch (e) { 10546 console.error('Error saving folder state to localStorage:', e); 10547 } 10548} 10549 10550window.toggleFolder = toggleFolder; 10551 10552/** 10553 * Toggle password protection for a gallery/page 10554 * @param {number} id - The gallery ID 10555 * @param {Event} event - The click event 10556 */ 10557function toggleGalleryPasswordProtection(id, event) { 10558 event.stopPropagation(); 10559 10560 const gallery = findGalleryById(galleries, id); 10561 if (!gallery) { 10562 console.error("Could not find gallery for password protection toggle, ID:", id); 10563 return; 10564 } 10565 10566 if (gallery.passwordProtected) { 10567 // Remove password protection 10568 if (confirm('Remove password protection from this page?')) { 10569 gallery.passwordProtected = false; 10570 delete gallery.password; 10571 saveGalleries(); 10572 renderGalleries(); 10573 } 10574 } else { 10575 // Add password protection 10576 const password = prompt('Enter a password for this page:'); 10577 if (password && password.trim()) { 10578 gallery.passwordProtected = true; 10579 gallery.password = password.trim(); 10580 saveGalleries(); 10581 renderGalleries(); 10582 } 10583 } 10584} 10585 10586window.toggleGalleryPasswordProtection = toggleGalleryPasswordProtection; 10587 10588function onPageMenuChromeToggle(checkbox) { 10589 if (!checkbox || !checkbox.checked) return; 10590 const root = checkbox.closest ? checkbox.closest('li') : null; 10591 if (!root) return; 10592 const siblingClass = checkbox.classList.contains('edit-hide-menu') ? '.edit-overlay-menu' : '.edit-hide-menu'; 10593 const sibling = root.querySelector(siblingClass); 10594 if (sibling && sibling !== checkbox) sibling.checked = false; 10595} 10596window.onPageMenuChromeToggle = onPageMenuChromeToggle; 10597 10598/** 10599 * Saves the edited gallery item details. 10600 * (UPDATED to handle Meta Title and Meta Description, removed Parent Menu logic) 10601 * @param {number} id - The ID of the gallery item. 10602 * @param {Event} event - The click event. 10603 */ 10604function saveEdit(id, event) { 10605 event.stopPropagation(); 10606 const li = getGalleryTreeListItem(id, event); 10607 if (!li) { 10608 console.error("Could not find list item for saveEdit, ID:", id); 10609 return; 10610 } 10611 10612 const titleInput = li.querySelector('.edit-title'); 10613 const slugInput = li.querySelector('.edit-slug'); 10614 const externalUrlInput = li.querySelector('.edit-external-url'); 10615 const metaTitleInput = li.querySelector('.edit-meta-title'); 10616 const metaDescriptionInput = li.querySelector('.edit-meta-description'); 10617 const hideMenuInput = li.querySelector('.edit-hide-menu'); 10618 const overlayMenuInput = li.querySelector('.edit-overlay-menu'); 10619 const actsAsMenuParentInput = li.querySelector('.edit-acts-as-menu-parent'); 10620 const passwordProtectedInput = li.querySelector('.edit-password-protected'); 10621 const passwordInput = li.querySelector('.edit-password'); 10622 const noIndexInput = li.querySelector('.edit-no-index'); 10623 10624 const title = titleInput ? titleInput.value.trim() : ''; 10625 const newSlugRaw = slugInput ? slugInput.value.trim() : null; 10626 const externalUrl = externalUrlInput ? externalUrlInput.value.trim() : ''; 10627 const metaTitle = metaTitleInput ? metaTitleInput.value.trim() : ''; 10628 const metaDescription = metaDescriptionInput ? metaDescriptionInput.value.trim() : ''; 10629 const hideMenu = hideMenuInput ? hideMenuInput.checked : false;
10630 const overlayMenu = overlayMenuInput ? overlayMenuInput.checked : false; 10631 const passwordProtected = passwordProtectedInput ? passwordProtectedInput.checked : false; 10632 const password = passwordInput ? passwordInput.value.trim() : ''; 10633 // noIndex is stored explicitly so it survives visibility changes without reverting 10634 const noIndex = noIndexInput ? noIndexInput.checked : undefined; 10635 10636 const gallery = findGalleryById(galleries, id); 10637 if (!gallery) { 10638 console.error("Could not find gallery object for saveEdit, ID:", id); 10639 renderGalleries(); 10640 return; 10641 } 10642 10643 if (!title && !gallery.isSpacer) { 10644 alert('Please enter a title'); 10645 return; 10646 } 10647 10648 // Validate password protection 10649 if (passwordProtected && !password) { 10650 alert('Please enter a password when password protection is enabled'); 10651 return; 10652 } 10653 10654 // Only mutate the opt-in property when the user explicitly changes this checkbox. 10655 // This preserves the serialized shape and behavior of every existing item. 10656 if (actsAsMenuParentInput) { 10657 const wasNavigableMenuParent = gallery.actsAsMenuParent === true; 10658 const wantsNavigableMenuParent = actsAsMenuParentInput.checked === true; 10659 if (wasNavigableMenuParent && !wantsNavigableMenuParent) { 10660 const hasNestedItems = galleries.some(item => item.parentId === gallery.id); 10661 if (hasNestedItems) { 10662 alert('Move all nested menu items out of this item before turning off âAllow nested menu itemsâ.'); 10663 return; 10664 } 10665 delete gallery.actsAsMenuParent; 10666 } else if (!wasNavigableMenuParent && wantsNavigableMenuParent) { 10667 gallery.actsAsMenuParent = true; 10668 } 10669 } 10670 10671 gallery.title = title; 10672 // Sidebar SEO fields are only shown for galleries; do not overwrite stored values when saving a page. 10673 if (!gallery.isPage) { 10674 gallery.metaTitle = metaTitle; 10675 gallery.metaDescription = metaDescription; 10676 } 10677 // Hide-menu wins if both are somehow checked; overlay is opt-in and omitted when off. 10678 if (hideMenu) { 10679 gallery.hideMenuOnPage = true; 10680 delete gallery.overlayMenuOnPage; 10681 } else { 10682 gallery.hideMenuOnPage = hideMenu; 10683 if (overlayMenu) { 10684 gallery.overlayMenuOnPage = true; 10685 } else { 10686 delete gallery.overlayMenuOnPage; 10687 } 10688 } 10689 // Persist the explicit noIndex choice so it survives future visibility toggles 10690 if (noIndex !== undefined) gallery.noIndex = noIndex; 10691 10692 // Save password protection settings 10693 gallery.passwordProtected = passwordProtected; 10694 if (passwordProtected) { 10695 gallery.password = password; 10696 } else { 10697 delete gallery.password; // Remove password if protection is disabled 10698 } 10699 10700 if ((gallery.isPage || gallery.isIntegrated) && newSlugRaw !== null) { 10701 const originalSlug = gallery.slug || (gallery.url && gallery.url.startsWith('/') ? gallery.url.substring(1) : ''); 10702 let sanitizedSlug = slugify(newSlugRaw); 10703 10704 if (!sanitizedSlug) { 10705 sanitizedSlug = slugify(gallery.title); 10706 } 10707 10708 if (sanitizedSlug !== originalSlug) { 10709 const otherGalleries = galleries.filter(g => g.id !== id); 10710 gallery.slug = ensureUniqueSlug(sanitizedSlug, otherGalleries); 10711 gallery.url = `/${gallery.slug}`; 10712 } else { 10713 if (gallery.url !== `/${gallery.slug}`) { 10714 gallery.url = `/${gallery.slug}`; 10715 } 10716 } 10717 } 10718 10719 // Update external URL if this is an external link 10720 if (gallery.isExternal && externalUrl) { 10721 gallery.url = externalUrl; 10722 } 10723 10724 saveGalleries(); 10725 renderGalleries(); 10726 10727 // If the currently active gallery was just edited, re-apply menu visibility 10728 if (gallery.id === activeGalleryId) { 10729 const bodyEl = document.body; 10730 if (shouldApplyMenuHiddenForEmbedOrGallery(gallery)) { 10731 bodyEl.classList.add('menu-hidden-on-page'); 10732 } else { 10733 bodyEl.classList.remove('menu-hidden-on-page'); 10734 } 10735 applyMenuOverlayClassForGallery(gallery); 10736 } 10737} 10738// Helper function to check if a gallery is an ancestor of another 10739function hasAncestor(galleryId, ancestorId) { 10740if (galleryId === ancestorId) return true; 10741 10742const gallery = galleries.find(g => g.id === galleryId); 10743if (!gallery || !gallery.parentId) return false; 10744 10745if (gallery.parentId === ancestorId) return true; 10746 10747return hasAncestor(gallery.parentId, ancestorId); 10748} 10749 10750function deleteGallery(id, event) { 10751 event.stopPropagation(); 10752 const gallery = galleries.find(g => g.id === id); 10753 const title = gallery && (gallery.title || gallery.slug || 'this item') || 'this item'; 10754 if (!confirm('Are you sure you want to delete "' + title + '"?')) { 10755 return; 10756 }
10757 const overlay = showDeletionOverlay(); 10758 overlay.updateMessage('Saving...'); 10759 galleries = galleries.filter(g => g.id !== id); 10760 if (typeof window !== 'undefined') { 10761 window.galleries = galleries; 10762 } 10763 if (activeGalleryId === id) { 10764 activeGalleryId = null; 10765 const frame = document.getElementById('galleryFrame'); 10766 if (frame) frame.src = ''; 10767 } 10768 saveGalleries() 10769 .then(function(success) { 10770 renderGalleries(); 10771 overlay.hide(); 10772 if (success === false) { 10773 alert('Failed to delete. Please try again.'); 10774 } 10775 }) 10776 .catch(function(err) { 10777 overlay.hide(); 10778 console.error('Error deleting item:', err); 10779 alert('Failed to delete. Please try again.'); 10780 }); 10781} 10782 10783function debugNestedStructure() { 10784console.group('Current Gallery Structure'); 10785 10786function printGallery(gallery, level = 0) { 10787const indent = ' '.repeat(level); 10788console.log(`${indent}${gallery.title} (ID: ${gallery.id}, Parent: ${gallery.parentId || 'none'})`); 10789 10790if (gallery.children && gallery.children.length > 0) { 10791 gallery.children.forEach(child => printGallery(child, level + 1)); 10792} 10793} 10794 // Create a tree structure for debugging 10795const galleryTree = createGalleryTree(galleries); 10796galleryTree.forEach(gallery => printGallery(gallery)); 10797 10798console.groupEnd(); 10799} 10800 10801function toggleHome(id, event) { 10802 event.stopPropagation(); 10803 10804 console.log('Setting home page to gallery:', id); 10805 10806 // Find gallery 10807 const gallery = galleries.find(g => g.id === id); 10808 if (!gallery) return; 10809 10810 // Check if this gallery is already set as home 10811 const alreadyHome = gallery.isHomePage === true; 10812 10813 // Set all galleries to not be the home page 10814 galleries.forEach(g => { 10815 g.isHomePage = false; 10816 }); 10817 10818 // Toggle this gallery's home status - if it was already home, we're unsetting it 10819 gallery.isHomePage = !alreadyHome; 10820 10821 // Save changes 10822 saveGalleries(); 10823 10824 // Update UI 10825 renderGalleries(); 10826 10827 // If this gallery is now the home page, display a message 10828 if (gallery.isHomePage) { 10829 const title = gallery.title || 'This page'; 10830 showSuccessMessage(`${title} is now set as the Home page`); 10831 } 10832} 10833 10834// Function to toggle gallery visibility 10835function toggleVisibility(id, event) { 10836 event.stopPropagation(); 10837 10838 const gallery = galleries.find(g => g.id === id); 10839 if (gallery) { 10840 // Toggle the visibility 10841 gallery.visible = gallery.visible === false ? true : false; 10842 10843 // Save the change to server 10844 saveGalleries(); 10845 10846 // Update UI - important to toggle the class on the button itself 10847 const toggle = event.currentTarget; 10848 if (toggle) { 10849 if (gallery.visible === false) { 10850 toggle.classList.add('hidden'); 10851 10852 // Update the SVG icon 10853 toggle.querySelector('svg').innerHTML = '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line>'; 10854 } else { 10855 toggle.classList.remove('hidden'); 10856 10857 // Update the SVG icon 10858 toggle.querySelector('svg').innerHTML = '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle>'; 10859 } 10860 } 10861 10862 // Update parent row class (#galleryTree row, not horizontal submenu li) 10863 const listItem = getGalleryTreeListItem(id, event); 10864 if (listItem) { 10865 if (gallery.visible === false) { 10866 listItem.classList.add('hidden-gallery'); 10867 } else { 10868 listItem.classList.remove('hidden-gallery'); 10869 } 10870 } 10871 10872 // Only clear iframe if not in edit mode 10873 if (id === activeGalleryId && gallery.visible === false && !isEditing) { 10874 document.getElementById('galleryFrame').src = ''; 10875 } 10876 10877 // Re-render menus based on current layout 10878 const currentLayout = document.body.classList.contains('menu-layout-horizontal') ? 'horizontal' : 10879 document.body.classList.contains('menu-layout-top') ? 'top' : 'sidebar'; 10880 10881 if (currentLayout === 'horizontal' && typeof window.renderHorizontalMenu === 'function') { 10882 window.renderHorizontalMenu(); 10883 } 10884 10885 // Also re-render the sidebar gallery tree to ensure hidden-gallery class is properly applied 10886 // This is especially important when horizontal layout is active but we're viewing the sidebar in edit mode 10887 if (typeof window.renderGalleries === 'function') { 10888 window.renderGalleries(); 10889 } else if (typeof renderGalleries === 'function') { 10890 renderGalleries(); 10891 } 10892 } 10893} 10894 10895 10896 10897/** 10898* Simplified loadGallery function that follows the same content swap pattern 10899* @param {number} id - The gallery ID 10900* @param {Event} event - Optional event object 10901*/ 10902// Enhanced loadGallery function with script cleanup 10903async function loadGallery(id, event) { 10904 10905// Stop event propagation if provided; preventDefault keeps <a href> SPA navigation 10906if (event) { 10907 if (typeof event.preventDefault === 'function') event.preventDefault(); 10908 event.stopPropagation(); 10909} 10910 10911const galleryContainer = document.querySelector('.gallery-container'); 10912if (galleryContainer) { 10913 // Apply immediate hiding styles 10914 galleryContainer.style.opacity = '0'; 10915} 10916 10917console.log('Loading gallery with ID:', id); 10918
10919if (typeof dismissGalleryPasswordOverlay === 'function') { 10920 dismissGalleryPasswordOverlay(); 10921} 10922 10923// Find gallery by ID 10924const gallery = findGalleryById(galleries, id); // Assuming `galleries` is accessible 10925 10926if (!gallery) { 10927 console.warn('No gallery found with ID:', id); 10928 if (galleryContainer) { 10929 galleryContainer.style.opacity = '1'; 10930 } 10931 return; 10932} 10933 10934// Check if this is a page, if so use loadPage instead 10935if (isPageMenuItem(gallery)) { 10936 // Use the loadPage function defined in this scope (or window.loadPage if you prefer) 10937 return loadPage(id, event); 10938} 10939 10940// Skip submenu items 10941if (gallery.isSubmenu) { 10942 console.log('Gallery is submenu, skipping URL update'); 10943 toggleSubmenu(id, event || { stopPropagation: () => {} }); // Assuming toggleSubmenu is available 10944 if (galleryContainer) { 10945 galleryContainer.style.opacity = '1'; 10946 } // Show container 10947 return; 10948} 10949 10950console.log('Found gallery:', gallery.title); 10951 10952window._activeContentType = 'gallery'; 10953beginHydraContentLoad('gallery', gallery.id, gallery.pageId); 10954if (window.PageManager && typeof window.PageManager.clearCurrentPage === 'function') { 10955 window.PageManager.clearCurrentPage(); 10956} 10957 10958// CRITICAL: Set active gallery ID consistently in both global and window scope 10959console.log('Setting active gallery ID to:', id, '(Previous value:', (window.activeGalleryId || activeGalleryId), ')'); 10960window.activeGalleryId = id; 10961activeGalleryId = id; // Ensure both variables are synchronized 10962 10963// Save reference to which gallery we're loading (for later verification) 10964window.currentLoadingGalleryId = id; 10965 10966// CRITICAL: Thorough cleanup with await to ensure completion 10967if (typeof window.removeGalleryScriptsWithPause === 'function') { 10968 await window.removeGalleryScriptsWithPause(); 10969} 10970 10971 10972// Clear all existing content from gallery container 10973if (!galleryContainer) { 10974 console.error('Gallery container not found'); 10975 return; 10976} 10977 10978// Force clear all content 10979galleryContainer.innerHTML = ''; 10980 10981 10982 // CRITICAL: Double-check active gallery ID before updating UI 10983 if (window.activeGalleryId !== id || activeGalleryId !== id) { 10984 console.warn('Active gallery ID changed unexpectedly before UI update, restoring to:', id); 10985 window.activeGalleryId = id; 10986 activeGalleryId = id; 10987 } 10988 10989// Apply menu visibility based on the gallery's setting and edit mode 10990const bodyEl = document.body; 10991if (shouldApplyMenuHiddenForEmbedOrGallery(gallery)) { 10992 bodyEl.classList.add('menu-hidden-on-page'); 10993} else { 10994 bodyEl.classList.remove('menu-hidden-on-page'); // Ensure menu is visible if in edit mode or not hidden 10995} 10996applyMenuOverlayClassForGallery(gallery); 10997 10998// Update UI state using explicit window function references or local fallbacks 10999if (typeof window.updateActiveStates === 'function') { 11000 window.updateActiveStates(); 11001} else if (typeof updateActiveStates === 'function') { 11002 updateActiveStates(); 11003} 11004 11005if (typeof window.updateMobileTitle === 'function') { 11006 window.updateMobileTitle(); 11007} else if (typeof updateMobileTitle === 'function') { 11008 updateMobileTitle(); 11009} 11010 11011if (typeof window.closeMobileMenu === 'function') { 11012 window.closeMobileMenu(); 11013} else if (typeof closeMobileMenu === 'function') { 11014 closeMobileMenu(); 11015} 11016 11017// Update URL with gallery slug 11018if (typeof window.updateURLWithGallerySlug === 'function') { 11019 window.updateURLWithGallerySlug(gallery); 11020} else if (typeof updateURLWithGallerySlug === 'function') { 11021 updateURLWithGallerySlug(gallery); 11022} 11023 11024 // Create gallery container and load content 11025const neonGalleryContainer = document.createElement('div'); 11026neonGalleryContainer.id = 'neon-gallery-container'; 11027neonGalleryContainer.className = 'gallery-direct-content'; 11028neonGalleryContainer.setAttribute('data-gallery-id', gallery.id.toString()); 11029neonGalleryContainer.style.width = '100%'; 11030neonGalleryContainer.style.height = '100%'; 11031galleryContainer.appendChild(neonGalleryContainer); 11032 11033// Add a cache-busting parameter to ensure script is freshly loaded 11034const timestamp = Date.now(); 11035 11036// Set up gallery parameters 11037window.Parameters = { 11038 SiteAlias: window.location.hostname, 11039 InitialPageUuid: gallery.pageId, 11040 InitialPageAlias: gallery.slug || slugify(gallery.title), // Assuming slugify is available 11041 isInEditor: window.isEditing || false, // Assuming window.isEditing is available 11042 siteId: window.siteId || gallery.siteId || '', // Assuming window.siteId is available 11043 isHydra: true,
11044 galleryInstanceId: timestamp, 11045 // Added loadedGalleryId as per your original function 11046 loadedGalleryId: gallery.id 11047}; 11048 11049// Debug: Check what galleryOptions contains 11050console.error('ð INDEX.TS DEBUG - Gallery:', gallery.title, 'has isPerma:', gallery.galleryOptions?.isPerma, 'permaURL:', gallery.galleryOptions?.permaURL, 'loadTxId:', gallery.galleryOptions?.loadTxId, 'loadPermaURL:', gallery.galleryOptions?.loadPermaURL); 11051 11052// Set up gallery config 11053window.neonGalleryConfig = { 11054 useData: true, 11055 useCDN: true, 11056 version: 'live', 11057 manualCollectionName: gallery.galleryOptions?.manualCollectionName || "mod", 11058 layoutType: gallery.galleryOptions?.layoutType || "grid", 11059 // Spread gallery options AFTER setting defaults to ensure current gallery's settings take precedence 11060 // This ensures that only properties from the current gallery are included, not from previous galleries 11061 ...(gallery.galleryOptions || {}), 11062 // CRITICAL: Explicitly include Load Network fields to ensure they're passed to gallery script 11063 // These fields are required for the gallery to determine if it should use Load Network racing 11064 loadTxId: gallery.galleryOptions?.loadTxId || null, 11065 loadPermaURL: gallery.galleryOptions?.loadPermaURL || null, 11066 // Cloudflare CDN NDJSON (unique id) â when present, gallery loads CDN-first (3s) then Fly 11067 cdnNdjsonId: gallery.galleryOptions?.cdnNdjsonId || null, 11068 cdnNdjsonUrl: gallery.galleryOptions?.cdnNdjsonUrl || null, 11069 cdnNdjsonStableUrl: gallery.galleryOptions?.cdnNdjsonStableUrl || gallery.galleryOptions?.embedPointerUrl || null, 11070 embedPointerUrl: gallery.galleryOptions?.embedPointerUrl || gallery.galleryOptions?.cdnNdjsonStableUrl || null, 11071 cdnSaveTimestamp: gallery.galleryOptions?.cdnSaveTimestamp || null, 11072 siteId: window.siteId || gallery.siteId || '', // Assuming window.siteId is available 11073 // Added galleryId and galleryInstanceId as per your original function - these OVERRIDE spread 11074 galleryId: gallery.id, 11075 galleryInstanceId: timestamp 11076}; 11077 11078console.error('ð INDEX.TS DEBUG - After spread, window.neonGalleryConfig.isPerma:', window.neonGalleryConfig.isPerma, 'permaURL:', window.neonGalleryConfig.permaURL, 'loadTxId:', window.neonGalleryConfig.loadTxId, 'loadPermaURL:', window.neonGalleryConfig.loadPermaURL, 'cdnNdjsonId:', window.neonGalleryConfig.cdnNdjsonId); 11079 11080// Create and add the gallery script with cache-busting 11081// Use Worker route (avoids CDN SSL issues) 11082const script = document.createElement('script'); 11083const galleryScriptsBase = window.location.origin + '/gallery-scripts/'; 11084script.src = galleryScriptsBase + 'neon-gallery-main-v260923-002.js'; // Using versioned file 11085script.setAttribute('data-gallery-id', gallery.id.toString()); // Keep data-gallery-id attribute 11086script.setAttribute('data-timestamp', timestamp.toString()); // Keep data-timestamp attribute 11087console.log('Loading gallery-main script from Worker:', script.src); 11088script.onerror = function() { 11089 console.error('â Gallery script failed, retrying via proxy:', script.src); 11090 const proxyUrl = script.src.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/'); 11091 script.src = proxyUrl; 11092 script.onerror = function() { 11093 console.error('â Proxy failed, trying storage:', proxyUrl); 11094 script.src = script.src.replace('cdn.neonsky.app', 'storage.neonsky.app'); 11095 }; 11096}; 11097 11098// Added script.onload from your original function 11099script.onload = function() { 11100 if (window.currentLoadingGalleryId !== gallery.id) { 11101 console.warn('Gallery ID mismatch! Expected:', gallery.id, 11102 'Current:', window.currentLoadingGalleryId); 11103 } 11104 console.log(`Gallery script loaded for: ${gallery.title} (ID: ${gallery.id})`); 11105}; 11106 11107 11108if (galleryContainer) { 11109 setTimeout(() => { 11110 galleryContainer.style.opacity = '1'; 11111 }, 50); // Small delay to ensure content is ready 11112 } 11113 11114console.log(`Loading fresh gallery script for: ${gallery.title}`); 11115document.body.appendChild(script); 11116} 11117 11118 11119 11120function removeGalleryScriptsWithPause() { 11121 return new Promise(resolve => { 11122 console.log('Performing comprehensive gallery cleanup (v2 - refined selectors)'); 11123 11124 // 1. Capture and clear any gallery data in localStorage (remains the same) 11125 try { 11126 const localStorageKeys = Object.keys(localStorage); 11127 const galleryStorageKeys = localStorageKeys.filter(key => 11128 key.includes('gallery') || 11129 key.includes('neon') || 11130 key.includes('lightbox') 11131 ); 11132 galleryStorageKeys.forEach(key => { 11133 localStorage.removeItem(key); 11134 }); 11135 } catch (e) { 11136 console.warn('Error clearing localStorage:', e); 11137 } 11138 11139 // @ts-ignore 11140 window._galleryPageContext = null; // (remains the same) 11141 11142 // Keep HP gallery CSS. Page-element edit/view remounts skip HP reload; 11143 // stripping neon-gallery-hp-hydra.css zeros masonry-item / container margins. 11144 const galleryStyles = document.querySelectorAll('style[data-gallery], link[href*="neon-gallery"]');
11145 galleryStyles.forEach(style => { 11146 const href = (style && style.href) ? String(style.href) : ''; 11147 if (style && style.id === 'neon-gallery-css') return; 11148 if (href.indexOf('neon-gallery-hp-hydra') !== -1) return; 11149 if (style && style.parentNode) { 11150 style.parentNode.removeChild(style); 11151 } 11152 }); 11153 11154 // 4. Remove lightbox elements (more specific selectors now) 11155 // These are specific IDs and classes for lightbox components. 11156 const lightboxComponentSelectors = [ 11157 '#neon-lightbox', // Main lightbox container by ID 11158 '.neon-lightbox', // Alternative class for main container 11159 '.lightbox-container',// General container class if used 11160 // Add any other *specific* classes or IDs your lightbox system uses for its top-level elements 11161 ]; 11162 const lightboxElements = document.querySelectorAll(lightboxComponentSelectors.join(', ')); 11163 lightboxElements.forEach(lightboxEl => { 11164 if (lightboxEl && lightboxEl.parentNode && lightboxEl !== document.body && lightboxEl !== document.documentElement) { 11165 // console.log('Removing lightbox component:', lightboxEl.id || lightboxEl.className); 11166 lightboxEl.parentNode.removeChild(lightboxEl); 11167 } 11168 }); 11169 11170 // 5. Remove other gallery-specific DOM elements 11171 // REMOVED: '[class*="lightbox"]', '[id*="lightbox"]' from this list 11172 // The selector '[class*="neon-"]' is still broad but less likely to match body unless you add "neon-" class to body. 11173 const galleryElementSelectors = [ 11174 '.neon-gallery-wrapper', '.gallery-container .fullscreen-overlay', 11175 '.neon-gallery-modal', '.gallery-tooltip', '.neon-gallery-context-menu', 11176 '.neon-thumbnails', '.neon-image', '.neon-caption', 11177 '.neon-controls', '.neon-pagination', 11178 '[class*="neon-"]', // BE CAUTIOUS: If this matches body or critical layout elements, it can cause issues. 11179 '[data-gallery-id]', '[data-image-id]', '.image-grid', '.image-masonry' 11180 // Ensure none of these selectors inadvertently match document.body or critical layout containers 11181 ]; 11182 const galleryElements = document.querySelectorAll(galleryElementSelectors.join(', ')); 11183 11184 if (galleryElements.length > 0) { 11185 galleryElements.forEach(el => { 11186 // CRITICAL FIX: Add checks to ensure we don't remove body or html 11187 if (el && el.parentNode && el !== document.body && el !== document.documentElement) { 11188 // console.log('Removing gallery-specific DOM element:', el.id || el.className); 11189 el.parentNode.removeChild(el); 11190 } else if (el === document.body || el === document.documentElement) { 11191 console.warn(`[CRITICAL WARNING] Attempt to remove document.body or document.documentElement blocked by selector: ${el.id || el.className}. Review your selectors.`); 11192 } 11193 }); 11194 } 11195 11196 // 6. Find and remove ALL gallery scripts (remains the same) 11197 const scripts = document.querySelectorAll( 11198 'script[src*="neon-gallery-main-hydra"], script[src*="neon-gallery-main-dev.js"], ' + 11199 'script[src*="gallery"], script[data-gallery-id], script[data-gallery], script[data-timestamp]' 11200 ); 11201 scripts.forEach(script => { 11202 if (script && script.parentNode) { 11203 // console.log('Removing script:', script.src || script.getAttribute('data-gallery-id')); 11204 script.parentNode.removeChild(script); 11205 } 11206 }); 11207 11208 // 6b. Specifically remove gallery options scripts (but NOT CSS - CSS is needed across galleries) 11209 const optionsScripts = document.querySelectorAll( 11210 'script[src*="galleryOptions"], script[data-options-js]' 11211 ); 11212 optionsScripts.forEach(script => { 11213 if (script && script.parentNode) { 11214 console.log('Removing gallery options script:', script.src || script.getAttribute('data-options-js')); 11215 script.parentNode.removeChild(script); 11216 } 11217 }); 11218 11219 // NOTE: We intentionally do NOT remove gallery options CSS here 11220 // The CSS is needed across galleries and doesn't cause conflicts like scripts do 11221 11222 setTimeout(() => { 11223 const remainingScripts = document.querySelectorAll( 11224 'script[src*="neon-gallery-main-hydra"], script[src*="neon-gallery-main-dev.js"], ' + 11225 'script[src*="gallery"], script[data-gallery-id], script[data-gallery], script[data-timestamp]' 11226 ); 11227 if (remainingScripts.length > 0) { 11228 // console.warn(`[DEBUG] ${remainingScripts.length} gallery scripts still found after removal attempt.`); 11229 } 11230 }, 50); 11231 11232 // 7. Reset ALL known global state variables used by the gallery (remains the same) 11233 const resetGlobals = [ 11234 'neonGalleryInitComplete', 'neonGalleryInitInProgress', 11235 'neonGalleryLoaded', 'neonGalleryConfig', 'neonGalleryState', 11236 'neonGalleryCache', 'neonGalleryImages', 'neonGallerySettings', 11237 'neonLightbox', 'neonGalleryEventListeners', 'neonGalleryInstance', 11238 'currentGalleryId', 'galleryData', 'imageCache', 'thumbnailCache', 11239 ]; 11240
11241 resetGlobals.forEach(prop => { 11242 // @ts-ignore 11243 if (window[prop] !== undefined) { 11244 // @ts-ignore 11245 window[prop] = null; 11246 } 11247 }); 11248 // Keep the bootstrap wipe flag. Clearing it let a re-injected main script 11249 // replace PageManager embed config with the default "mod" collection. 11250 if (typeof window.resetNeonGalleryInitGates === 'function') { 11251 window.resetNeonGalleryInitGates('removeGalleryScriptsWithPause'); 11252 } 11253 11254 // 7b. Reset gallery options panel state 11255 if (window._editorButtonVisibilityInterval) { 11256 clearInterval(window._editorButtonVisibilityInterval); 11257 window._editorButtonVisibilityInterval = null; 11258 } 11259 const editorBtn = document.getElementById('editorButton'); 11260 if (editorBtn) { 11261 editorBtn.classList.add('gallery-options-force-hidden'); 11262 editorBtn.style.setProperty('display', 'none', 'important'); 11263 editorBtn.style.pointerEvents = ''; 11264 } 11265 if (typeof window.syncGalleryOptionsButtonVisibility === 'function') { 11266 window.syncGalleryOptionsButtonVisibility(); 11267 } 11268 // Reset optionsVisible flag if it exists on window (for cross-module access) 11269 // @ts-ignore 11270 if (window.optionsVisible !== undefined) { 11271 // @ts-ignore 11272 window.optionsVisible = false; 11273 } 11274 11275 // Reset options-related globals 11276 const optionsGlobals = [ 11277 'generateOptionsUI', 'openOptionsOverlay', 'showOptionsPanel', 11278 'hideOptionsPanel', 'toggleOptionsOverlay', 'optionsVisible' 11279 ]; 11280 optionsGlobals.forEach(prop => { 11281 // @ts-ignore 11282 if (window[prop] !== undefined && typeof window[prop] !== 'function') { 11283 // Only reset non-function properties (like flags) 11284 // Functions should remain available 11285 // @ts-ignore 11286 if (prop === 'optionsVisible') { 11287 // @ts-ignore 11288 window[prop] = false; 11289 } 11290 } 11291 }); 11292 11293 // Reset EnhancedGallerySystem so a pending initPromise cannot hang forever 11294 // after scripts it was waiting on were just removed from the DOM. 11295 if (window.EnhancedGallerySystem && typeof window.EnhancedGallerySystem.resetInitializationState === 'function') { 11296 window.EnhancedGallerySystem.resetInitializationState(); 11297 } 11298 11299 // Reset PageManager page-embedded gallery tracking 11300 window.neonGalleryScriptLoaded = false; 11301 window.pageManagerGalleryInitBlocked = false; 11302 if (window.pageManagerGalleryContainers) { 11303 window.pageManagerGalleryContainers.clear(); 11304 } 11305 11306 // Remove orphaned lightbox keyboard listeners (survive script tag removal) 11307 if (typeof window.removeAllLightboxKeyboardListeners === 'function') { 11308 window.removeAllLightboxKeyboardListeners(); 11309 } else if (window.currentLightboxKeyboardListener) { 11310 document.removeEventListener('keydown', window.currentLightboxKeyboardListener); 11311 window.currentLightboxKeyboardListener = null; 11312 window.lightboxKeyboardListenerActive = false; 11313 } 11314 11315 // Short pause before resolving 11316 setTimeout(resolve, 150); 11317 }); 11318} 11319 11320 11321// Fixed clearAddForm function with proper variable names 11322function clearAddForm() { 11323// Clear the title input 11324const titleInput = document.getElementById('galleryTitle'); 11325if (titleInput) { 11326titleInput.value = ''; 11327} 11328 11329// Clear the URL input 11330const urlInput = document.getElementById('galleryUrl'); 11331if (urlInput) { 11332urlInput.value = ''; 11333} 11334 11335// Reset the parent selection 11336const parentSelect = document.getElementById('galleryParent'); 11337if (parentSelect) { 11338parentSelect.value = ''; 11339} 11340 11341// Reset radio buttons to Gallery (integrated) as default 11342const galleryRadio = document.querySelector('input[name="galleryType"][value="integrated"]'); 11343if (galleryRadio) { 11344galleryRadio.checked = true; 11345} 11346 11347// Hide URL input container when default is Gallery 11348const urlInputContainer = document.getElementById('urlInputContainer'); 11349if (urlInputContainer) { 11350urlInputContainer.style.display = 'none'; 11351} else { 11352console.log('URL input container not found'); 11353} 11354} 11355 11356/** 11357 * Ensures window.galleries is synchronized with the global galleries variable 11358 * Call this function after any modification to gallery settings 11359 * 11360 * @returns {boolean} True if synchronization was successful 11361 */ 11362/** 11363 * Enhanced synchronizeGalleries function that checks ALL galleries 11364 * not just imported ones, and ensures settings are preserved 11365 * @returns {boolean} Success indicator 11366 */ 11367function synchronizeGalleries() { 11368 console.log('Starting enhanced gallery synchronization...'); 11369 11370 if (typeof galleries !== 'undefined' && Array.isArray(galleries)) { 11371 // First check if window.galleries exists and is different from galleries 11372 const needsSync = !window.galleries || 11373 !Array.isArray(window.galleries) || 11374 window.galleries.length !== galleries.length; 11375 11376 // ENHANCED: Check ALL galleries for options preservation, not just imported ones 11377 if (!needsSync && window.galleries.length > 0) { 11378 // Find all galleries with galleryOptions 11379 const galleriesWithOptions = galleries.filter(g => 11380 g.galleryOptions && Object.keys(g.galleryOptions).length > 4 11381 ); 11382 11383 if (galleriesWithOptions.length > 0) { 11384 // Check ALL galleries with substantial options, not just a sample 11385 let syncNeeded = false;
11386 11387 // Use for loop so we can break early if we find a mismatch 11388 for (let i = 0; i < galleriesWithOptions.length; i++) { 11389 const gallery = galleriesWithOptions[i]; 11390 const windowGallery = window.galleries.find(g => g.id === gallery.id); 11391 11392 if (windowGallery) { 11393 // Get options depth 11394 const galleryOptionsDepth = gallery.galleryOptions ? 11395 Object.keys(gallery.galleryOptions).length : 0; 11396 11397 const windowOptionsDepth = windowGallery.galleryOptions ? 11398 Object.keys(windowGallery.galleryOptions).length : 0; 11399 11400 // If options depth differs significantly, we need to sync 11401 if (Math.abs(galleryOptionsDepth - windowOptionsDepth) > 2) { 11402 console.log(`Gallery sync needed: Options mismatch detected for ${gallery.title}`); 11403 console.log(`Options count: global=${galleryOptionsDepth}, window=${windowOptionsDepth}`); 11404 11405 syncNeeded = true; 11406 break; // No need to check more galleries 11407 } 11408 11409 // Additional check: Look for specific important gallery settings that should be preserved 11410 // Only do this check for galleries with substantial settings 11411 if (galleryOptionsDepth > 5 && windowOptionsDepth > 5) { 11412 // Critical keys that indicate gallery customization 11413 const criticalKeys = ['columns', 'spacing', 'layoutType', 'startInSingles', 11414 'autoplaySingles', 'autoplayRandomStart', 'lightboxBgColor']; 11415 11416 // Check if any critical keys are missing in window.galleries 11417 const missingKeys = criticalKeys.filter(key => 11418 gallery.galleryOptions[key] !== undefined && 11419 windowGallery.galleryOptions[key] === undefined 11420 ); 11421 11422 if (missingKeys.length > 0) { 11423 console.log(`Gallery sync needed: Critical settings missing in window.galleries for ${gallery.title}`); 11424 console.log(`Missing keys: ${missingKeys.join(', ')}`); 11425 11426 syncNeeded = true; 11427 break; // No need to check more galleries 11428 } 11429 } 11430 } 11431 } 11432 11433 // If we detected a need to sync, do it now 11434 if (syncNeeded) { 11435 // Ensure we preserve rich gallery settings and per-page custom HTML fields 11436 window.galleries = galleries.map(gallery => { 11437 const windowGallery = window.galleries.find(g => g.id === gallery.id); 11438 if (!windowGallery) return gallery; 11439 11440 let merged = { ...gallery }; 11441 11442 if (windowGallery.galleryOptions && gallery.galleryOptions) { 11443 const globalOptionsDepth = Object.keys(gallery.galleryOptions).length; 11444 const windowOptionsDepth = Object.keys(windowGallery.galleryOptions).length; 11445 if (windowOptionsDepth > globalOptionsDepth) { 11446 merged = { 11447 ...merged, 11448 galleryOptions: { 11449 ...windowGallery.galleryOptions, 11450 ...gallery.galleryOptions 11451 } 11452 }; 11453 } 11454 } 11455 11456 if (windowGallery.customPageHtmlR2Key || windowGallery.customPageHtmlEnabled) { 11457 merged = { 11458 ...merged, 11459 customPageHtmlEnabled: windowGallery.customPageHtmlEnabled, 11460 customPageHtmlR2Key: windowGallery.customPageHtmlR2Key, 11461 customPageHtmlFilename: windowGallery.customPageHtmlFilename 11462 }; 11463 } 11464 11465 return merged; 11466 }); 11467 11468 console.log(`Synchronized window.galleries with global galleries (${galleries.length} items)`); 11469 return true; 11470 } 11471 } 11472 } 11473 11474 // If we determined sync is needed (or as a precaution), update window.galleries 11475 if (needsSync || !window.galleries) { 11476 if (window.galleries && Array.isArray(window.galleries)) { 11477 galleries = galleries.map(function(gallery) { 11478 const windowGallery = window.galleries.find(function(g) { return g && g.id === gallery.id; }); 11479 if (!windowGallery) return gallery; 11480 if (windowGallery.customPageHtmlR2Key || windowGallery.customPageHtmlEnabled) { 11481 return { 11482 ...gallery, 11483 customPageHtmlEnabled: windowGallery.customPageHtmlEnabled, 11484 customPageHtmlR2Key: windowGallery.customPageHtmlR2Key, 11485 customPageHtmlFilename: windowGallery.customPageHtmlFilename 11486 }; 11487 } 11488 return gallery; 11489 }); 11490 } 11491 window.galleries = [...galleries]; // Create a shallow copy to ensure different reference 11492 console.log(`Synchronized window.galleries with global galleries (${galleries.length} items)`); 11493 } else { 11494 console.log('Gallery synchronization check: No sync needed'); 11495 } 11496 11497 // Verify that all galleries with rich settings have their full options preserved 11498 const richGalleries = galleries.filter(g => 11499 g.galleryOptions && Object.keys(g.galleryOptions).length > 5 11500 ); 11501 11502 console.log(`Status: Found ${richGalleries.length} galleries with rich settings`); 11503 11504 // Final verification - keep both arrays aligned without dropping page-level fields 11505 if (window.galleries !== galleries) { 11506 console.warn('References not identical after sync - merging page fields into global galleries'); 11507 if (window.galleries && Array.isArray(window.galleries)) { 11508 galleries = galleries.map(function(gallery) { 11509 const windowGallery = window.galleries.find(function(g) { return g && g.id === gallery.id; }); 11510 if (!windowGallery) return gallery; 11511 return { 11512 ...gallery, 11513 customPageHtmlEnabled: windowGallery.customPageHtmlEnabled ?? gallery.customPageHtmlEnabled, 11514 customPageHtmlR2Key: windowGallery.customPageHtmlR2Key ?? gallery.customPageHtmlR2Key, 11515 customPageHtmlFilename: windowGallery.customPageHtmlFilename ?? gallery.customPageHtmlFilename, 11516 hideMenuOnPage: windowGallery.hideMenuOnPage ?? gallery.hideMenuOnPage, 11517 overlayMenuOnPage: windowGallery.overlayMenuOnPage ?? gallery.overlayMenuOnPage 11518 }; 11519 }); 11520 } 11521 window.galleries = galleries; 11522 return true; 11523 } 11524 11525 return true; 11526 } else { 11527 console.warn('Cannot synchronize galleries: global galleries variable is undefined or not an array'); 11528 11529 // If we have window.galleries but no global galleries, try to reverse-sync 11530 if (window.galleries && Array.isArray(window.galleries) && window.galleries.length > 0) { 11531 console.log('Attempting reverse sync: updating global galleries from window.galleries');
11532 try { 11533 // This is risky and should only happen in unusual circumstances 11534 galleries = window.galleries; 11535 return true; 11536 } catch (e) { 11537 console.error('Could not reverse-sync galleries:', e); 11538 } 11539 } 11540 11541 return false; 11542 } 11543} 11544 11545 11546 11547/** 11548 * Loads NDJSON content from the original gallery 11549 * @param {string} siteId - The site ID 11550 * @param {string} pageId - The page ID of the original gallery 11551 * @returns {Promise<string|null>} - The NDJSON content or null if not found/error 11552 */ 11553async function loadGalleryNDJSON(siteId, pageId) { 11554 console.error('[NDJSON Duplication] loadGalleryNDJSON called with siteId:', siteId, 'pageId:', pageId); 11555 11556 if (!siteId || !pageId) { 11557 console.error('[NDJSON Duplication] ERROR: Cannot load NDJSON - missing siteId or pageId', { siteId: siteId, pageId: pageId }); 11558 return null; 11559 } 11560 11561 try { 11562 const ndjsonFilename = `${siteId}_${pageId}.ndjson`; 11563 const url = `https://fly.storage.tigris.dev/ns-bridge-pub/${ndjsonFilename}`; 11564 11565 console.error('[NDJSON Duplication] Attempting to load NDJSON from:', url); 11566 console.error('[NDJSON Duplication] Filename:', ndjsonFilename); 11567 11568 const response = await fetch(url); 11569 11570 console.error('[NDJSON Duplication] Fetch response status:', response.status, response.statusText); 11571 11572 if (!response.ok) { 11573 if (response.status === 404) { 11574 console.error('[NDJSON Duplication] ERROR: NDJSON file not found (404) - gallery may not have been published yet'); 11575 console.error('[NDJSON Duplication] Attempted URL:', url); 11576 return null; 11577 } 11578 const errorText = await response.text().catch(() => 'Could not read error text'); 11579 console.error('[NDJSON Duplication] ERROR: Failed to load NDJSON:', response.status, response.statusText, errorText); 11580 throw new Error(`Failed to load NDJSON: ${response.statusText}`); 11581 } 11582 11583 const ndjsonContent = await response.text(); 11584 console.error('[NDJSON Duplication] SUCCESS: Loaded NDJSON, length:', ndjsonContent.length, 'characters'); 11585 console.error('[NDJSON Duplication] First 200 chars:', ndjsonContent.substring(0, 200)); 11586 return ndjsonContent; 11587 } catch (error) { 11588 console.error('[NDJSON Duplication] ERROR: Exception loading NDJSON:', error); 11589 console.error('[NDJSON Duplication] Error message:', error.message); 11590 console.error('[NDJSON Duplication] Error stack:', error.stack); 11591 return null; 11592 } 11593} 11594 11595/** 11596 * Duplicates and publishes NDJSON for a gallery 11597 * @param {Object} originalGallery - The original gallery object 11598 * @param {Object} newGallery - The new gallery object with new pageId 11599 * @returns {Promise<boolean>} - Success indicator 11600 */ 11601async function duplicateGalleryNDJSON(originalGallery, newGallery) { 11602 console.error('[NDJSON Duplication] ===== STARTING duplicateGalleryNDJSON ====='); 11603 console.error('[NDJSON Duplication] Original gallery:', { 11604 id: originalGallery.id, 11605 title: originalGallery.title, 11606 pageId: originalGallery.pageId, 11607 classicGuid: originalGallery.classicGuid, 11608 hasGalleryOptions: !!originalGallery.galleryOptions, 11609 manualCollectionName: originalGallery.galleryOptions?.manualCollectionName, 11610 isClassicCollection: originalGallery.galleryOptions?.isClassicCollection 11611 }); 11612 console.error('[NDJSON Duplication] New gallery:', { 11613 id: newGallery.id, 11614 title: newGallery.title, 11615 pageId: newGallery.pageId 11616 }); 11617 11618 try { 11619 // Check if this is an NDJSON-based gallery (not classic collection) 11620 // IMPORTANT: A gallery can have a GUID-style manualCollectionName but still use NDJSON files 11621 // Only skip if it's explicitly marked as a classic collection OR has no pageId (which means no NDJSON) 11622 // Having a pageId means it uses NDJSON files, so we should duplicate them 11623 const isClassicCollection = originalGallery.galleryOptions?.isClassicCollection === true; 11624 11625 console.error('[NDJSON Duplication] Classic collection check:', { 11626 hasClassicGuid: !!originalGallery.classicGuid, 11627 manualCollectionName: originalGallery.galleryOptions?.manualCollectionName, 11628 startsWithGUID: originalGallery.galleryOptions?.manualCollectionName?.startsWith('G
11628UID='), 11629 isClassicCollectionFlag: originalGallery.galleryOptions?.isClassicCollection, 11630 hasPageId: !!originalGallery.pageId, 11631 isClassicCollection: isClassicCollection, 11632 willSkip: isClassicCollection && !originalGallery.pageId 11633 }); 11634 11635 // Only skip if explicitly marked as classic collection AND has no pageId 11636 // If it has a pageId, it uses NDJSON files and we should duplicate them 11637 if (isClassicCollection && !originalGallery.pageId) { 11638 console.error('[NDJSON Duplication] SKIP: Gallery is explicitly marked as classic collection with no pageId, skipping NDJSON duplication'); 11639 return true; // Not an error, just not applicable 11640 } 11641 11642 // Check if original gallery has a pageId 11643 if (!originalGallery.pageId) { 11644 console.error('[NDJSON Duplication] SKIP: Original gallery has no pageId, skipping NDJSON duplication'); 11645 console.error('[NDJSON Duplication] Original gallery keys:', Object.keys(originalGallery)); 11646 return true; // Not an error, just not applicable 11647 } 11648 11649 // Get siteId 11650 const siteId = window.siteId || originalGallery.siteId; 11651 console.error('[NDJSON Duplication] SiteId resolution:', { 11652 windowSiteId: window.siteId, 11653 gallerySiteId: originalGallery.siteId, 11654 resolvedSiteId: siteId 11655 }); 11656 11657 if (!siteId) { 11658 console.error('[NDJSON Duplication] ERROR: Cannot duplicate NDJSON - missing siteId'); 11659 console.error('[NDJSON Duplication] window.siteId:', window.siteId); 11660 console.error('[NDJSON Duplication] originalGallery.siteId:', originalGallery.siteId); 11661 return false; 11662 } 11663 11664 // Load original NDJSON 11665 console.error('[NDJSON Duplication] Loading original NDJSON for duplication...'); 11666 console.error('[NDJSON Duplication] Using siteId:', siteId, 'pageId:', originalGallery.pageId); 11667 const originalNdjson = await loadGalleryNDJSON(siteId, originalGallery.pageId); 11668 11669 if (!originalNdjson) { 11670 console.error('[NDJSON Duplication] ERROR: Could not load original NDJSON, gallery may not have been published yet'); 11671 console.error('[NDJSON Duplication] This means the original gallery\'s NDJSON file was not found'); 11672 return false; 11673 } 11674 11675 // Publish the duplicated NDJSON with new pageId using the same endpoint as edition publisher 11676 console.error('[NDJSON Duplication] Publishing duplicated NDJSON with new pageId:', newGallery.pageId); 11677 const dataFileName = `${siteId}_${newGallery.pageId}.ndjson`; 11678 console.error('[NDJSON Duplication] New filename:', dataFileName); 11679 console.error('[NDJSON Duplication] NDJSON content length:', originalNdjson.length); 11680 11681 try { 11682 // Hydra sites require Bearer auth (same token resolution as Edition Publisher) 11683 const publishHeaders = { 'Content-Type': 'application/json' }; 11684 let authToken = null; 11685 try { 11686 if (window.TokenManager) { 11687 if (typeof window.TokenManager.getHydraAuthToken === 'function') { 11688 try { authToken = window.TokenManager.getHydraAuthToken(); } catch (e) {} 11689 } 11690 if (!authToken && typeof window.TokenManager.getBestToken === 'function') { 11691 try { 11692 const tokenWithBearer = window.TokenManager.getBestToken(); 11693 authToken = tokenWithBearer && tokenWithBearer.startsWith('Bearer ') 11694 ? tokenWithBearer.substring(7) 11695 : tokenWithBearer; 11696 } catch (e) {} 11697 } 11698 } 11699 if (!authToken) authToken = localStorage.getItem('hydra_auth_token'); 11700 if (!authToken) authToken = window.authToken || window.didToken; 11701 if (authToken) { 11702 publishHeaders['Authorization'] = authToken.includes('.') || authToken.startsWith('hydra:') 11703 ? 'Bearer ' + authToken 11704 : (authToken.startsWith('Bearer ') ? authToken : 'Bearer hydra:' + authToken); 11705 console.error('[NDJSON Duplication] Auth token attached for publish'); 11706 } else { 11707 console.error('[NDJSON Duplication] WARNING: No auth token â publish will likely fail with 401'); 11708 } 11709 } catch (authErr) { 11710 console.error('[NDJSON Duplication] Auth token lookup failed:', authErr.message); 11711 } 11712 11713 // Call the publish endpoint (same as edition publisher) 11714 console.error('[NDJSON Duplication] Calling publish endpoint: https://hydra-press-v2.fly.dev/publish'); 11715 const response = await fetch('https://hydra-press-v2.fly.dev/publish', { 11716 method: 'POST', 11717 headers: publishHeaders, 11718 body: JSON.stringify({ 11719 ndjsonContent: originalNdjson, 11720 dataFileName: dataFileName 11721 }), 11722 }); 11723 11724 console.error('[NDJSON Duplication] Publish response status:', response.status, response.statusText); 11725 console.error('[NDJSON Duplication] Response headers:', Object.fromEntries(response.headers.entries())); 11726 11727 if (!response.ok) { 11728 const errorText = await response.text().catch(() => 'Unknown error'); 11729 console.error('[NDJSON Duplication] ERROR: Failed to publish duplicated NDJSON:', response.status, errorText); 11730 console.error('[NDJSON Duplication] Response status:', response.status); 11731 console.error('[NDJSON Duplication] Response statusText:', response.statusText); 11732 return false;
11733 } 11734 11735 const result = await response.json(); 11736 console.error('[NDJSON Duplication] SUCCESS: Published duplicated NDJSON'); 11737 console.error('[NDJSON Duplication] Publish result:', JSON.stringify(result, null, 2)); 11738 console.error('[NDJSON Duplication] Publish result URLs:', result.urls); 11739 11740 // Note: The publish endpoint doesn't return Irys txId, so we don't update galleryOptions 11741 // with txId/permaURL here. If Irys upload is needed, it would need to be done separately. 11742 11743 console.error('[NDJSON Duplication] ===== duplicateGalleryNDJSON COMPLETED SUCCESSFULLY ====='); 11744 return true; 11745 } catch (error) { 11746 console.error('[NDJSON Duplication] ERROR: Exception publishing duplicated NDJSON:', error); 11747 console.error('[NDJSON Duplication] Error message:', error.message); 11748 console.error('[NDJSON Duplication] Error stack:', error.stack); 11749 return false; 11750 } 11751 } catch (error) { 11752 console.error('[NDJSON Duplication] ERROR: Unexpected error in duplicateGalleryNDJSON:', error); 11753 console.error('[NDJSON Duplication] Error message:', error.message); 11754 console.error('[NDJSON Duplication] Error stack:', error.stack); 11755 return false; 11756 } 11757} 11758 11759/** 11760 * Creates and shows a loading overlay for gallery deletion (same pattern as duplication). 11761 * @returns {Object} Object with hide() and updateMessage() to control the overlay 11762 */ 11763function showDeletionOverlay() { 11764 const existingOverlay = document.getElementById('deletion-overlay'); 11765 if (existingOverlay) existingOverlay.remove(); 11766 if (!document.querySelector('#deletion-overlay-styles')) { 11767 const style = document.createElement('style'); 11768 style.id = 'deletion-overlay-styles'; 11769 style.textContent = ` 11770 #deletion-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 99999999; display: flex; align-items: center; justify-content: center; cursor: not-allowed; pointer-events: all; } 11771 #deletion-overlay .deletion-message { background: rgba(255,255,255,0.95); padding: 30px 50px; font-family: Arial,sans-serif; font-size: 16px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; flex-direction: column; align-items: center; gap: 20px; } 11772 #deletion-overlay .deletion-spinner { width: 80px; height: 80px; color: #444; } 11773 `; 11774 document.head.appendChild(style); 11775 } 11776 const overlayEl = document.createElement('div'); 11777 overlayEl.id = 'deletion-overlay'; 11778 const messageEl = document.createElement('div'); 11779 messageEl.className = 'deletion-message'; 11780 const spinnerEl = document.createElement('div'); 11781 spinnerEl.className = 'deletion-spinner'; 11782 spinnerEl.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 24 24"><defs><filter id="svgSpinnersDeletion"><feGaussianBlur in="SourceGraphic" result="y" stdDeviation="1"/><feColorMatrix in="y" result="z" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7"/><feBlend in="SourceGraphic" in2="z"/></filter></defs><g filter="url(#svgSpinnersDeletion)"><circle cx="5" cy="12" r="4" fill="currentColor"><animate attributeName="cx" calcMode="spline" dur="2s" keySplines=".36,.62,.43,.99;.79,0,.58,.57" repeatCount="indefinite" values="5;8;5"/></circle><circle cx="19" cy="12" r="4" fill="currentColor"><animate attributeName="cx" calcMode="spline" dur="2s" keySplines=".36,.62,.43,.99;.79,0,.58,.57" repeatCount="indefinite" values="19;16;19"/></circle><animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/></g></svg>`; 11783 const textEl = document.createElement('div'); 11784 textEl.textContent = 'Deleting...'; 11785 textEl.style.textAlign = 'center'; 11786 messageEl.appendChild(spinnerEl); 11787 messageEl.appendChild(textEl); 11788 overlayEl.appendChild(messageEl); 11789 document.body.appendChild(overlayEl); 11790 return { 11791 hide: function() { if (overlayEl && overlayEl.parentNode) overlayEl.parentNode.removeChild(overlayEl); }, 11792 updateMessage: function(msg) { textEl.textContent = msg; } 11793 }; 11794} 11795 11796/** 11797 * Creates and shows a loading overlay for gallery duplication 11798 * @returns {Object} Object with hide function to remove the overlay 11799 */ 11800function showDuplicationOverlay() { 11801 // Remove any existing overlay first 11802 const existingOverlay = document.getElementById('duplication-overlay'); 11803 if (existingOverlay) { 11804 existingOverlay.remove(); 11805 } 11806 11807 // Add styles if not already present 11808 if (!document.querySelector('#duplication-overlay-styles')) { 11809 const style = document.createElement('style'); 11810 style.id = 'duplication-overlay-styles'; 11811 style.textContent = ` 11812 #duplication-overlay { 11813 position: fixed; 11814 top: 0; 11815 left: 0; 11816 right: 0; 11817 bottom: 0; 11818 background: rgba(0, 0, 0, 0.5); 11819 z-index: 99999999; 11820 display: flex; 11821 align-items: center; 11822 justify-content: center;
11823 cursor: not-allowed; 11824 pointer-events: all; 11825 } 11826 #duplication-overlay .duplication-message { 11827 background: rgba(255, 255, 255, 0.95); 11828 padding: 30px 50px; 11829 font-family: Arial, sans-serif; 11830 font-size: 16px; 11831 box-shadow: 0 4px 12px rgba(0,0,0,0.15); 11832 display: flex; 11833 flex-direction: column; 11834 align-items: center; 11835 gap: 20px; 11836 } 11837 #duplication-overlay .duplication-spinner { 11838 width: 80px; 11839 height: 80px; 11840 color: #444444; 11841 } 11842 `; 11843 document.head.appendChild(style); 11844 } 11845 11846 const overlay = document.createElement('div'); 11847 overlay.id = 'duplication-overlay'; 11848 11849 const message = document.createElement('div'); 11850 message.className = 'duplication-message'; 11851 11852 const spinner = document.createElement('div'); 11853 spinner.className = 'duplication-spinner'; 11854 spinner.innerHTML = ` 11855 <svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 24 24"> 11856 <defs> 11857 <filter id="svgSpinnersGooeyBalls20"> 11858 <feGaussianBlur in="SourceGraphic" result="y" stdDeviation="1"/> 11859 <feColorMatrix in="y" result="z" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7"/> 11860 <feBlend in="SourceGraphic" in2="z"/> 11861 </filter> 11862 </defs> 11863 <g filter="url(#svgSpinnersGooeyBalls20)"> 11864 <circle cx="5" cy="12" r="4" fill="currentColor"> 11865 <animate attributeName="cx" calcMode="spline" dur="2s" keySplines=".36,.62,.43,.99;.79,0,.58,.57" repeatCount="indefinite" values="5;8;5"/> 11866 </circle> 11867 <circle cx="19" cy="12" r="4" fill="currentColor"> 11868 <animate attributeName="cx" calcMode="spline" dur="2s" keySplines=".36,.62,.43,.99;.79,0,.58,.57" repeatCount="indefinite" values="19;16;19"/> 11869 </circle> 11870 <animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/> 11871 </g> 11872 </svg> 11873 `; 11874 11875 const text = document.createElement('div'); 11876 text.textContent = 'Duplicating gallery...'; 11877 text.style.textAlign = 'center'; 11878 11879 message.appendChild(spinner); 11880 message.appendChild(text); 11881 overlay.appendChild(message); 11882 document.body.appendChild(overlay); 11883 11884 return { 11885 hide: function() { 11886 if (overlay && overlay.parentNode) { 11887 overlay.parentNode.removeChild(overlay); 11888 } 11889 }, 11890 updateMessage: function(newMessage) { 11891 text.textContent = newMessage; 11892 } 11893 }; 11894} 11895 11896/** 11897 * Duplicates a gallery or page item. 11898 * @param {number} id - The ID of the item to duplicate. 11899 * @param {Event} event - The click event. 11900 */ 11901async function duplicateGalleryItem(id, event) { 11902 event.stopPropagation(); 11903 console.log(`Duplicating item with ID: ${id}`); 11904 11905 // Show loading overlay 11906 const overlay = showDuplicationOverlay(); 11907 11908 // Find the original item in the global galleries array 11909 const originalItem = findGalleryById(galleries, id); // Use helper if available, otherwise simple find 11910 11911 if (!originalItem) { 11912 console.error(`Cannot duplicate: Item with ID ${id} not found.`); 11913 overlay.hide(); 11914 alert('Error: Could not find the item to duplicate.'); 11915 return; 11916 } 11917 11918 if (isPageMenuItem(originalItem)) { 11919 overlay.updateMessage('Duplicating page...'); 11920 } 11921 11922 try { 11923 // --- Create a Deep Copy --- 11924 // Using JSON.parse/stringify is a common way for simple object structures 11925 // Be cautious if your objects have Dates, functions, Maps, Sets, etc. 11926 const newItem = JSON.parse(JSON.stringify(originalItem)); 11927 11928 // --- Modify Copied Item --- 11929 newItem.id = Date.now() + Math.floor(Math.random() * 1000); // Generate a new unique ID 11930 newItem.title = `${originalItem.title}-copy`; // Append "-copy" to title 11931 11932 // Generate new pageId if it's a page or integrated gallery 11933 if (newItem.isPage || newItem.isIntegrated) { 11934 newItem.pageId = generatePageId(); // Assign a new unique pageId 11935 } 11936 11937 // Generate new slug and URL 11938 const newBaseSlug = slugify(newItem.title); // Use the new title 11939 newItem.slug = ensureUniqueSlug(newBaseSlug, galleries); // Ensure uniqueness 11940 newItem.url = `/${newItem.slug}`; // Update URL based on new slug 11941 11942 // Reset children (don't duplicate children for simplicity) 11943 newItem.children = []; 11944 11945 // Deep clone galleryOptions and pageElements, update pageId if needed 11946 if (newItem.galleryOptions) { 11947 newItem.galleryOptions = JSON.parse(JSON.stringify(newItem.galleryOptions)); 11948 // Update pageId within options if it exists and matches the old one 11949 if (newItem.galleryOptions.pageId && newItem.galleryOptions.pageId === originalItem.pageId) { 11950 newItem.galleryOptions.pageId = newItem.pageId; 11951 } 11952 // Clear permanent-storage pointers so duplicate loads its own Tigris NDJSON, not the original 11953 ['txId', 'permaURL', 'loadTxId', 'loadPermaURL', 'loadNetworkUrl', 'r2StorageUrl', 11954 'cdnNdjsonId', 'cdnNdjsonUrl', 'cdnNdjsonStableUrl', 'embedPointerUrl', 'cdnSaveTimestamp', 11955 'flyIoSaveTimestamp', 'loadNetworkSaveTimestamp', 'irysSaveTimestamp', 'timestampsMatch', 'isPerma'] 11956 .forEach(function(field) { delete newItem.galleryOptions[field]; }); 11957 } 11958 if (newItem.pageElements) { 11959 newItem.pageElements = JSON.parse(JSON.stringify(newItem.pageElements)); 11960 // Give new IDs to duplicated page elements
11961 newItem.pageElements.forEach(el => { 11962 el.id = Date.now() + Math.floor(Math.random() * 10000); 11963 }); 11964 } 11965 11966 // Reset home page status 11967 newItem.isHomePage = false; 11968 11969 // --- Duplicate NDJSON if applicable (galleries only; pages copy content via pageElements) --- 11970 const isDuplicatingPage = isPageMenuItem(originalItem); 11971 console.error('[Gallery Duplication] Checking if NDJSON duplication is needed...', { 11972 isDuplicatingPage, 11973 newItemPageId: newItem.pageId, 11974 originalItemPageId: originalItem.pageId 11975 }); 11976 11977 if (!isDuplicatingPage && newItem.pageId && originalItem.pageId) { 11978 overlay.updateMessage('Duplicating gallery files...'); 11979 console.error('[Gallery Duplication] Both galleries have pageIds, attempting to duplicate NDJSON file...'); 11980 const ndjsonDuplicated = await duplicateGalleryNDJSON(originalItem, newItem); 11981 if (!ndjsonDuplicated) { 11982 console.error('[Gallery Duplication] WARNING: NDJSON duplication failed, but continuing with gallery duplication'); 11983 console.error('[Gallery Duplication] User can republish the gallery later if needed'); 11984 alert('Gallery menu item was created, but gallery images could not be copied. Open Edition Publisher on this gallery and publish to load images.'); 11985 // Don't block the duplication if NDJSON fails - user can republish later 11986 } else { 11987 console.error('[Gallery Duplication] SUCCESS: NDJSON duplication completed successfully'); 11988 } 11989 } else { 11990 console.error('[Gallery Duplication] SKIP: Skipping NDJSON duplication:', { 11991 isDuplicatingPage, 11992 hasNewPageId: !!newItem.pageId, 11993 hasOriginalPageId: !!originalItem.pageId, 11994 reason: isDuplicatingPage ? 'Page items use pageElements, not NDJSON' : 11995 !newItem.pageId ? 'New gallery has no pageId' : 'Original gallery has no pageId' 11996 }); 11997 } 11998 11999 overlay.updateMessage(isDuplicatingPage ? 'Saving duplicated page...' : 'Saving duplicated gallery...'); 12000 12001 // --- Insert into Galleries Array --- 12002 // Find the index of the original item 12003 const originalIndex = galleries.findIndex(item => item.id === id); 12004 12005 if (originalIndex > -1) { 12006 // Insert the new item right after the original 12007 galleries.splice(originalIndex + 1, 0, newItem); 12008 console.log(`Inserted duplicate "${newItem.title}" after "${originalItem.title}"`); 12009 12010 // Update positions for all items after the insertion point 12011 for (let i = originalIndex + 1; i < galleries.length; i++) { 12012 galleries[i].position = i; // Re-assign position based on new index 12013 } 12014 12015 } else { 12016 // Fallback: Add to the end if original couldn't be found (shouldn't happen) 12017 console.warn(`Original item ${id} not found in array, adding duplicate to the end.`); 12018 newItem.position = galleries.length; 12019 galleries.push(newItem); 12020 } 12021 12022 console.log('Rendering galleries immediately after duplication...'); 12023 renderGalleries(); 12024 // Update active states if necessary (though unlikely needed here) 12025 updateActiveStates(); 12026 12027 // --- Save and Refresh --- 12028 console.log('Saving duplicated item...'); 12029 saveGalleries(null, { addedNewItem: true }) // Pass flag to trigger refresh 12030 .then(success => { 12031 overlay.hide(); // Hide overlay before page refresh 12032 if (success) { 12033 console.log('Duplicate saved successfully. Page will refresh.'); 12034 // No need to call renderGalleries() here because saveGalleries will trigger a reload. 12035 } else { 12036 console.error('Failed to save the duplicated item.'); 12037 alert('Error: Could not save the duplicated item.'); 12038 // Optionally try to revert the galleries array change here if save fails 12039 } 12040 }) 12041 .catch(error => { 12042 overlay.hide(); // Hide overlay on error 12043 console.error('Error saving duplicated item:', error); 12044 alert('Error saving duplicated item.'); 12045 }); 12046 12047 } catch (error) { 12048 overlay.hide(); // Hide overlay on error 12049 console.error('Error duplicating item:', error); 12050 alert('An error occurred while duplicating the item.'); 12051 } 12052} 12053 12054/** 12055 * saveGalleries function that always saves FULL data 12056 * Ensures gallery settings are preserved in both localStorage and server 12057 * 12058 * @param {Object} customData - Optional custom data to save instead of galleries 12059 * @returns {Promise<boolean>} - Success indicator 12060 */ 12061 12062function attachLiveSidebarElements(data) { 12063 if (!data || typeof data !== 'object') return data; 12064 var hasGetter = !!(window.SidebarManager && typeof window.SidebarManager.getElementsForSave === 'function'); 12065 if (hasGetter) { 12066 var live = window.SidebarManager.getElementsForSave(); 12067 if (Array.isArray(live) && live.length > 0) { 12068 data.sidebarElements = live; 12069 return data; 12070 } 12071 delete data.sidebarElements; 12072 return data; 12073 } 12074 if (Array.isArray(data.sidebarElements) && data.sidebarElements.length === 0) { 12075 delete data.sidebarElements; 12076 } 12077 return data; 12078} 12079 12080async function saveGalleries(customData) { 12081 if (!isAuthenticated && localStorage.getItem('hydra_is_admin') !== 'true') { 12082 alert('You must be logged in as an admin to save changes'); 12083 return false;
12084 } 12085 12086 if (window.HelpSystem && typeof window.HelpSystem.isSandboxActive === 'function' && window.HelpSystem.isSandboxActive()) { 12087 console.warn('Save blocked: help sandbox is active'); 12088 return false; 12089 } 12090 12091 function _hasHelpDemoInTree(items) { 12092 if (!Array.isArray(items)) return false; 12093 for (var i = 0; i < items.length; i++) { 12094 var g = items[i]; 12095 if (!g) continue; 12096 if (g.isHelpDemo || Number(g.id) < 0) return true; 12097 if (g.children && _hasHelpDemoInTree(g.children)) return true; 12098 } 12099 return false; 12100 } 12101 12102 if (window.activeGalleryId != null && Number(window.activeGalleryId) < 0) { 12103 console.warn('Save blocked: active help demo gallery'); 12104 return false; 12105 } 12106 12107 var savePayload = customData && customData.galleries ? customData.galleries : galleries; 12108 if (_hasHelpDemoInTree(savePayload)) { 12109 console.warn('Save blocked: payload contains help demo records'); 12110 return false; 12111 } 12112 12113 // Prevent multiple simultaneous saves â queue the latest payload instead of dropping it 12114 if (window._saveInProgress) { 12115 console.log('Save already in progress, queueing latest payload'); 12116 if (customData) { 12117 window._pendingSaveGalleriesData = attachLiveSidebarElements(customData); 12118 } 12119 return new Promise(function(resolve) { 12120 window._pendingSaveResolvers = window._pendingSaveResolvers || []; 12121 window._pendingSaveResolvers.push(resolve); 12122 }); 12123 } 12124 12125 window._saveInProgress = true; 12126 12127 try { 12128 // CRITICAL: Synchronize galleries before saving to ensure we use the latest data 12129 if (typeof synchronizeGalleries === 'function') { 12130 synchronizeGalleries(); 12131 console.log('Galleries synchronized before saving'); 12132 } 12133 12134 // Try to get a valid token from multiple sources 12135 let token = null; 12136 let tokenSource = 'none'; 12137 12138 // First try the global didToken (traditional approach) 12139 if (didToken) { 12140 console.log('Using global didToken for save operation'); 12141 token = didToken; 12142 tokenSource = 'didToken'; 12143 } 12144 // Next try TokenManager if available 12145 else if (window.TokenManager && typeof window.TokenManager.getToken === 'function') { 12146 token = window.TokenManager.getToken(); 12147 12148 // Remove Bearer prefix if present (we'll add it later) 12149 if (token && token.startsWith('Bearer ')) { 12150 token = token.substring(7); 12151 } 12152 12153 console.log('Using TokenManager token for save operation, length:', token ? token.length : 0); 12154 tokenSource = 'TokenManager'; 12155 } 12156 // Fallback to localStorage 12157 else if (localStorage.getItem('hydra_auth_token')) { 12158 token = localStorage.getItem('hydra_auth_token'); 12159 console.log('Using localStorage token for save operation, length:', token.length); 12160 tokenSource = 'localStorage'; 12161 } 12162 // Last resort: try to get a fresh token 12163 else if (magic && magic.user) { 12164 try { 12165 console.log('Attempting to get fresh token from Magic'); 12166 token = await magic.user.getIdToken(); 12167 console.log('Obtained fresh token from Magic:', token ? 'success' : 'failed'); 12168 tokenSource = token ? 'magic' : 'none'; 12169 } catch (e) { 12170 console.error('Error getting token from Magic:', e); 12171 tokenSource = 'magic_error'; 12172 } 12173 } 12174 12175 // [AUTH_SAVE] Log for S+J download - token state and logged-in email before request 12176 const loggedInEmail = (window.userMetadata && window.userMetadata.email) || 12177 localStorage.getItem('hydra_auth_email') || 12178 ''; 12179 console.log('[AUTH_SAVE] Token resolved:', { 12180 loggedInEmail: loggedInEmail || '(none)', 12181 tokenSource, 12182 tokenLength: token ? token.length : 0, 12183 tokenPrefix: token ? token.substring(0, 15) + '...' : '(none)', 12184 hasToken: !!token, 12185 isAuthenticated, 12186 hydraIsAdmin: localStorage.getItem('hydra_is_admin') === 'true', 12187 hasLocalStorageToken: !!localStorage.getItem('hydra_auth_token'), 12188 hasTokenManager: !!(window.TokenManager && typeof window.TokenManager.getToken === 'function') 12189 }); 12190 12191 if (!token) { 12192 console.error('[AUTH_SAVE] No token available - save will fail. Sources checked: didToken, TokenManager, localStorage, magic.'); 12193 throw new Error('Could not find a valid authentication token. Please log in again.'); 12194 } 12195 12196 // IMPORTANT: Try to save to localStorage with isolated error handling 12197 // This ensures localStorage failures don't prevent server saves 12198 if (!customData) { // Only try localStorage for regular gallery saves
12199 try { 12200 // FIXED: Always save the FULL galleries data to localStorage 12201 localStorage.setItem('galleries', JSON.stringify(galleries)); 12202 console.log(`Saved ${galleries.length} galleries to localStorage (FULL format with complete settings)`); 12203 } catch (storageError) { 12204 // Now storage errors are isolated and non-fatal 12205 console.warn('LocalStorage quota exceeded, proceeding without local backup', storageError); 12206 } 12207 } else if (customData && customData.isImport) { 12208 // Skip localStorage for import operations - data too large 12209 console.log('Skipping localStorage for import operation - data too large for local storage'); 12210 } 12211 12212 // IMPORTANT: Ensure all pages have proper URLs and slugs before saving 12213 galleries.forEach(gallery => { 12214 if (gallery.isPage === true) { 12215 const pageSlug = slugify(gallery.title); 12216 12217 // Make sure slug and URL are set correctly 12218 if (!gallery.slug || gallery.slug === '') { 12219 gallery.slug = pageSlug; 12220 } 12221 12222 // Make sure URL points to slug 12223 if (!gallery.url || gallery.url === '/' || !gallery.url.includes(gallery.slug)) { 12224 gallery.url = `/${pageSlug}`; 12225 } 12226 } 12227 }); 12228 12229 // Set up the data to save - ensure we're using the complete data 12230 let siteData; 12231 console.log('saveGalleries: customData received:', JSON.stringify(customData)); 12232 console.log('saveGalleries: siteMetadata in customData:', customData?.siteMetadata); 12233 console.log('saveGalleries: TITLE in customData:', customData?.siteMetadata?.title); 12234 console.log('saveGalleries: GOOGLE ANALYTICS in customData:', customData?.siteMetadata?.googleAnalytics); 12235 if (customData) { 12236 // CRITICAL FIX: Ensure gallery settings are preserved when saving from sidebar 12237 // If customData contains galleries (which it should for sidebar-initiated saves), 12238 // merge them with any existing settings to ensure nothing is lost 12239 if (customData.galleries && Array.isArray(customData.galleries)) { 12240 // First, ensure we preserve all current gallery settings from the existing galleries 12241 const existingGalleries = (typeof galleries !== 'undefined' ? galleries : window.galleries) || []; 12242 12243 // Create merged galleries array with preserved settings 12244 const mergedGalleries = customData.galleries.map(gallery => { 12245 const existingGallery = existingGalleries.find(g => g.id === gallery.id); 12246 12247 // If this gallery exists in the current dataset, ensure all galleryOptions are preserved 12248 if (existingGallery && existingGallery.galleryOptions && gallery.galleryOptions) { 12249 // Calculate settings depth 12250 const existingOptionsDepth = Object.keys(existingGallery.galleryOptions).length; 12251 const serverOptionsDepth = Object.keys(gallery.galleryOptions).length; 12252 12253 // If server has fewer settings, merge them with existing settings 12254 if (serverOptionsDepth < existingOptionsDepth && existingOptionsDepth > 5) { 12255 console.log(`Preserving rich settings for ${gallery.title} (${existingOptionsDepth} vs ${serverOptionsDepth})`); 12256 12257 // Create a merged gallery with preserved settings 12258 return { 12259 ...gallery, 12260 galleryOptions: { 12261 ...existingGallery.galleryOptions, // Start with all existing settings 12262 ...gallery.galleryOptions // Override with any new server settings 12263 } 12264 }; 12265 } 12266 } 12267 12268 // If no preservation needed, use server gallery as is 12269 return gallery; 12270 }); 12271 12272 // Use the merged galleries in customData 12273 siteData = { 12274 ...customData, 12275 galleries: mergedGalleries 12276 }; 12277 12278 console.log(`Settings-preservation merge completed: ${mergedGalleries.length} galleries`); 12279 console.log('saveGalleries: siteData after merge:', JSON.stringify(siteData)); 12280 console.log('saveGalleries: siteMetadata after merge:', siteData.siteMetadata); 12281 console.log('saveGalleries: TITLE after merge:', siteData.siteMetadata?.title); 12282 console.log('saveGalleries: GOOGLE ANALYTICS after merge:', siteData.siteMetadata?.googleAnalytics); 12283 } else { 12284 siteData = customData; 12285 } 12286 } else { 12287 const galleriesForSave = (typeof window !== 'undefined' && window.galleries && Array.isArray(window.galleries)) 12288 ? window.galleries 12289 : galleries; 12290 siteData = { 12291 galleries: galleriesForSave 12292 }; 12293 12294 if (window.siteMetadata) { 12295 siteData.siteMetadata = window.siteMetadata; 12296 } 12297 12298 if (window.SidebarManager) { 12299 const liveSidebar = typeof window.SidebarManager.getElementsForSave === 'function' 12300 ? window.SidebarManager.getElementsForSave() 12301 : null; 12302 if (Array.isArray(liveSidebar) && liveSidebar.length > 0) { 12303 siteData.sidebarElements = liveSidebar; 12304 } 12305 } 12306 } 12307 12308 attachLiveSidebarElements(siteData); 12309 12310 // Ensure galleries are never saved as pages: any item with galleryOptions is a gallery and must persist with isPage false.
12311 // (loadPage can set isPage=true in memory; sidebar/full saves would otherwise persist that and convert TEAM etc. to pages.) 12312 if (siteData.galleries && Array.isArray(siteData.galleries)) { 12313 siteData.galleries = siteData.galleries.map(g => { 12314 if (g && g.galleryOptions && typeof g.galleryOptions === 'object') { 12315 return { ...g, isPage: false }; 12316 } 12317 return g; 12318 }); 12319 } 12320 12321 // Get the save URL 12322 const saveUrl = typeof getApiUrl === 'function' ? 12323 getApiUrl('/api/save-config') : 12324 '/api/save-config'; 12325 12326 // Get user email from various sources 12327 const userEmail = (window.userMetadata && window.userMetadata.email) || 12328 localStorage.getItem('hydra_auth_email') || 12329 ''; 12330 12331 // Ensure token has proper format 12332 const formattedToken = token.startsWith('hydra:') ? token : 12333 (token.includes('.') ? token : `hydra:${token}`); 12334 12335 console.log('Making server save request...'); 12336 console.log('saveGalleries: Final siteData being sent to server:', JSON.stringify(siteData)); 12337 console.log('saveGalleries: Final siteMetadata being sent:', siteData.siteMetadata); 12338 console.log('saveGalleries: FINAL TITLE being sent:', siteData.siteMetadata?.title); 12339 console.log('saveGalleries: FINAL GOOGLE ANALYTICS being sent:', siteData.siteMetadata?.googleAnalytics); 12340 12341 // [AUTH_SAVE] Log for S+J download - request details (no full token) 12342 console.log('[AUTH_SAVE] Sending save request:', { 12343 saveUrl, 12344 authHeaderPrefix: `Bearer ${formattedToken.substring(0, 20)}...`, 12345 userEmail: userEmail || '(empty)', 12346 tokenSource, 12347 formattedTokenLength: formattedToken.length 12348 }); 12349 12350 // Make the request 12351 let response = await fetch(saveUrl, { 12352 method: 'POST', 12353 headers: { 12354 'Content-Type': 'application/json', 12355 'Authorization': `Bearer ${formattedToken}`, 12356 'X-User-Email': userEmail, 12357 'X-API-Request': 'true' // Always include this header for API requests 12358 }, 12359 body: JSON.stringify(siteData) 12360 }); 12361 12362 console.log('Save response status:', response.status); 12363 12364 // Process the response 12365 let responseText = await response.text(); 12366 console.log('Server response text (first 500 chars):', responseText.substring(0, 500)); 12367 let responseData; 12368 12369 try { 12370 responseData = JSON.parse(responseText); 12371 console.log('Server response parsed successfully:', responseData); 12372 if (responseData.debug) { 12373 console.log('Server debug info:', responseData.debug); 12374 console.log('Server debug - requestBodyKeys:', responseData.debug.requestBodyKeys); 12375 console.log('Server debug - hasSiteMetadata:', responseData.debug.hasSiteMetadata); 12376 console.log('Server debug - siteMetadata:', responseData.debug.siteMetadata); 12377 console.log('Server debug - finalSiteMetadata:', responseData.debug.finalSiteMetadata); 12378 console.log('Server debug - googleAnalytics:', responseData.debug.googleAnalytics); 12379 } 12380 } catch (jsonError) { 12381 if (response.status === 200 && responseText.trim().startsWith('<!DOCTYPE html>')) { 12382 responseData = { success: true }; 12383 } else { 12384 throw new Error('Invalid response format: ' + responseText.substring(0, 100)); 12385 } 12386 } 12387 12388 if (!response.ok && !responseData.success) { 12389 // [AUTH_SAVE] Log for S+J download - capture full 401/auth failure details 12390 const authSaveFailure = { 12391 status: response.status, 12392 statusText: response.statusText, 12393 error: responseData && responseData.error, 12394 details: responseData && responseData.details, 12395 authMethod: responseData && responseData.authMethod, 12396 fullResponse: responseData, 12397 responseTextPreview: responseText.substring(0, 300) 12398 }; 12399 console.error('[AUTH_SAVE] Save request failed:', authSaveFailure); 12400 12401 // Retry once on 401: token may have expired - try refresh or fresh Magic token 12402 if (response.status === 401) {
12403 console.log('[AUTH_SAVE] 401 received - attempting token refresh before retry...'); 12404 let newToken = null; 12405 if (window.TokenManager && typeof window.TokenManager.refreshToken === 'function') { 12406 newToken = await window.TokenManager.refreshToken(); 12407 } 12408 if (!newToken && magic && magic.user) { 12409 try { 12410 newToken = await magic.user.getIdToken(); 12411 } catch (e) { 12412 console.warn('[AUTH_SAVE] Magic getIdToken failed:', e); 12413 } 12414 } 12415 if (newToken) { 12416 if (newToken.startsWith('Bearer ')) newToken = newToken.substring(7); 12417 token = newToken; 12418 didToken = newToken; 12419 localStorage.setItem('hydra_auth_token', newToken); 12420 if (window.TokenManager && typeof window.TokenManager.storeToken === 'function') { 12421 window.TokenManager.storeToken(newToken.startsWith('hydra:') ? newToken : `hydra:${newToken}`, userEmail); 12422 } 12423 console.log('[AUTH_SAVE] Got fresh token, retrying save...'); 12424 const retryResponse = await fetch(saveUrl, { 12425 method: 'POST', 12426 headers: { 12427 'Content-Type': 'application/json', 12428 'Authorization': `Bearer ${token.startsWith('hydra:') ? token : (token.includes('.') ? token : `hydra:${token}`)}`, 12429 'X-User-Email': userEmail, 12430 'X-API-Request': 'true' 12431 }, 12432 body: JSON.stringify(siteData) 12433 }); 12434 const retryText = await retryResponse.text(); 12435 let retryData; 12436 try { retryData = JSON.parse(retryText); } catch (e) { 12437 if (retryResponse.status === 200 && retryText.startsWith('<!DOCTYPE')) retryData = { success: true }; 12438 else throw new Error('Invalid retry response'); 12439 } 12440 if (retryResponse.ok || retryData.success) { 12441 response = retryResponse; 12442 responseText = retryText; 12443 responseData = retryData; 12444 console.log('[AUTH_SAVE] Retry succeeded'); 12445 } else { 12446 console.error('[AUTH_SAVE] Retry also failed:', retryResponse.status, retryData); 12447 throw new Error(`Failed to save: ${response.status} - ${JSON.stringify(responseData)}`); 12448 } 12449 } else { 12450 throw new Error(`Failed to save: ${response.status} - ${JSON.stringify(responseData)}`); 12451 } 12452 } else { 12453 throw new Error(`Failed to save: ${response.status} - ${JSON.stringify(responseData)}`); 12454 } 12455 } 12456 12457 console.log('Configuration saved successfully'); 12458 12459 // Update local lastKnownConfigTimestamp after successful save 12460 if (responseData.lastModified) { 12461 window.lastKnownConfigTimestamp = responseData.lastModified; 12462 console.error('[CONFLICT DETECT] â Updated local config timestamp from save response:', window.lastKnownConfigTimestamp); 12463 } else { 12464 console.error('[CONFLICT DETECT] â ï¸ No lastModified in save response, fetching from server...'); 12465 // If server doesn't return timestamp, fetch it 12466 try { 12467 const timestampUrl = typeof getApiUrl === 'function' ? 12468 getApiUrl('/api/config-timestamp') : 12469 '/api/config-timestamp'; 12470 const timestampResponse = await fetch(timestampUrl); 12471 if (timestampResponse.ok) { 12472 const timestampData = await timestampResponse.json(); 12473 if (timestampData.lastModified) { 12474 window.lastKnownConfigTimestamp = timestampData.lastModified; 12475 console.error('[CONFLICT DETECT] â Fetched and updated local config timestamp:', window.lastKnownConfigTimestamp); 12476 } else { 12477 console.error('[CONFLICT DETECT] â ï¸ No lastModified in fetched timestamp data:', timestampData); 12478 } 12479 } else { 12480 console.error('[CONFLICT DETECT] â ï¸ Failed to fetch timestamp after save, status:', timestampResponse.status); 12481 } 12482 } catch (err) { 12483 console.error('[CONFLICT DETECT] â ï¸ Could not fetch timestamp after save:', err); 12484 } 12485 } 12486 12487 // Store new token if provided 12488 if (responseData.hydraToken) { 12489 console.log('Received new hydra token from server'); 12490 12491 // Update the global didToken 12492 didToken = responseData.hydraToken.startsWith('hydra:') ? 12493 responseData.hydraToken.substring(6) : responseData.hydraToken; 12494 12495 // Update localStorage 12496 localStorage.setItem('hydra_auth_token', didToken); 12497 12498 // Update TokenManager if available 12499 if (window.TokenManager && typeof window.TokenManager.storeToken === 'function') { 12500 window.TokenManager.storeToken(responseData.hydraToken, userEmail); 12501 } 12502 } 12503 12504 // Process server response data if needed 12505 if (responseData.galleries) { 12506 // If server returns updated galleries data, update the global galleries variable 12507 if (Array.isArray(responseData.galleries) && responseData.galleries.length > 0) { 12508 // CRITICAL FIX: Before replacing galleries with server response, 12509 // preserve any gallery settings that might be lost in the response 12510 const updatedGalleries = responseData.galleries.map(serverGallery => { 12511 // Find the matching gallery in our current data 12512 const existingGallery = galleries.find(g => g.id === serverGallery.id); 12513 12514 // Check if we need to preserve gallery settings 12515 if (existingGallery && existingGallery.galleryOptions && serverGallery.galleryOptions) { 12516 // Calculate settings depth 12517 const existingOptionsDepth = Object.keys(existingGallery.galleryOptions).length; 12518 const serverOptionsDepth = Object.keys(serverGallery.galleryOptions).length; 12519 12520 // If server has fewer settings, merge them with existing settings 12521 if (serverOptionsDepth < existingOptionsDepth && existingOptionsDepth > 5) { 12522 console.log(`Preserving rich settings for ${serverGallery.title} (${existingOptionsDepth} vs ${serverOptionsDepth})`); 12523 12524 // Create a merged gallery with preserved settings 12525 return { 12526 ...serverGallery, 12527 galleryOptions: { 12528 ...existingGallery.galleryOptions, // Start with all existing settings 12529 ...serverGallery.galleryOptions // Override with any new server settings 12530 } 12531 }; 12532 } 12533 } 12534 12535 // If no preservation needed, use server gallery as is 12536 return serverGallery; 12537 }); 12538 12539 // Update the galleries variable with our preserved data 12540 galleries = updatedGalleries; 12541 console.log(`Updated galleries from server response: ${galleries.length} galleries`); 12542 12543 // IMPORTANT: Synchronize again after receiving server response 12544 if (typeof synchronizeGalleries === 'function') { 12545 synchronizeGalleries(); 12546 console.log('Galleries re-synchronized after server response'); 12547 } 12548 12549 // Keep sidebar UI in sync with current galleries (e.g. after delete) 12550 if (typeof renderGalleries === 'function') { 12551 renderGalleries(); 12552 } 12553 12554 // Update localStorage with full data 12555 try { 12556 localStorage.setItem('galleries', JSON.stringify(galleries)); 12557 console.log('Updated localStorage with server gallery data (FULL format)'); 12558 } catch (e) { 12559 console.warn('Could not update localStorage with server gallery data', e); 12560 } 12561 } 12562 } 12563
12564 // CRITICAL FIX: Clear all sessionStorage caches after successful save 12565 // This ensures that when users open the site in a new tab, they get fresh data 12566 try { 12567 console.log('Clearing sessionStorage caches after successful save...'); 12568 const keysToRemove = []; 12569 for (let i = 0; i < sessionStorage.length; i++) { 12570 const key = sessionStorage.key(i); 12571 if (key && (key.includes('gallery_data_') || key.includes('neonGallery_'))) { 12572 keysToRemove.push(key); 12573 } 12574 } 12575 keysToRemove.forEach(key => { 12576 sessionStorage.removeItem(key); 12577 console.log('Cleared sessionStorage key: ' + key); 12578 }); 12579 console.log('Cleared ' + keysToRemove.length + ' sessionStorage cache entries'); 12580 } catch (cacheError) { 12581 console.warn('Error clearing sessionStorage cache:', cacheError); 12582 // Don't fail the save if cache clearing fails 12583 } 12584 12585 window._saveInProgress = false; 12586 12587 if (!customData || !Array.isArray(customData.sidebarElements)) { 12588 document.dispatchEvent(new CustomEvent('sidebar-save-requested')); 12589 } 12590 12591 // Flush any save queued while this request was in flight 12592 if (window._pendingSaveGalleriesData) { 12593 const pending = window._pendingSaveGalleriesData; 12594 window._pendingSaveGalleriesData = null; 12595 const resolvers = window._pendingSaveResolvers || []; 12596 window._pendingSaveResolvers = []; 12597 return saveGalleries(pending).then(function(result) { 12598 resolvers.forEach(function(r) { r(result); }); 12599 return result; 12600 }); 12601 } 12602 12603 const idleResolvers = window._pendingSaveResolvers || []; 12604 window._pendingSaveResolvers = []; 12605 idleResolvers.forEach(function(r) { r(true); }); 12606 12607 return true; 12608 } catch (error) { 12609 // [AUTH_SAVE] Log for S+J download - capture exception (network, parse, or thrown after 401) 12610 console.error('[AUTH_SAVE] Save threw exception:', { 12611 message: error.message, 12612 name: error.name, 12613 stack: error.stack 12614 }); 12615 console.error('Error saving configuration:', error); 12616 alert('Failed to save changes: ' + error.message); 12617 window._saveInProgress = false; 12618 const failResolvers = window._pendingSaveResolvers || []; 12619 window._pendingSaveResolvers = []; 12620 failResolvers.forEach(function(r) { r(false); }); 12621 return false; 12622 } 12623} 12624 12625// Helper function to get API URL for preview sites 12626function getApiUrl(endpoint) { 12627 // Check if we're on a preview URL 12628 const isPreviewUrl = window.location.hostname === 'preview.neonsky.app'; 12629 12630 if (isPreviewUrl) { 12631 // Extract the site GUID from the path (first segment after domain) 12632 const pathParts = window.location.pathname.split('/').filter(Boolean); 12633 if (pathParts.length > 0) { 12634 const siteGuid = pathParts[0]; 12635 // Include the site GUID in the API URL 12636 return `/${siteGuid}${endpoint}`; 12637 } 12638 } 12639 12640 // Regular case - return the endpoint as is 12641 return endpoint; 12642} 12643 12644function updateActiveStates() { 12645 // Get the most current active gallery ID, ensuring consistency between both variables 12646 const currentActiveId = window.activeGalleryId || activeGalleryId; 12647 12648 // Ensure both variables are synchronized 12649 window.activeGalleryId = currentActiveId; 12650 activeGalleryId = currentActiveId; 12651 12652 console.log("updateActiveStates: Using active gallery ID:", currentActiveId); 12653 12654 // Special case for invisible home page - don't try to highlight it in the menu 12655 const activeGallery = galleries.find(g => g.id === currentActiveId); 12656 if (activeGallery && activeGallery.visible === false && activeGallery.isHomePage === true) { 12657 console.log("Active gallery is invisible home page - not highlighting in menu"); 12658 // We're not returning here, so it will still update the visibility classes 12659 // but won't try to find an element to mark as active 12660 } 12661 12662 // Fix selector issue - look for both .tree and .sidebar 12663 document.querySelectorAll('.tree li, .sidebar li').forEach(li => { 12664 if (!li.dataset.id) return; // Skip items without data-id attribute 12665 12666 const id = parseInt(li.dataset.id); 12667 if (isNaN(id)) return; // Skip if ID is not a number 12668 12669 const gallery = galleries.find(g => g.id === id); 12670 12671 // Update active state using the most current ID 12672 const isActive = id === currentActiveId; 12673 12674 // Add or remove the active class 12675 if (isActive) { 12676 li.classList.add('active'); 12677 console.log(`Setting active class for element: ${id} (${gallery ? gallery.title : 'Unknown'})`); 12678 } else { 12679 li.classList.remove('active'); 12680 } 12681 12682 // Update visibility class if gallery exists 12683 if (gallery) { 12684 li.classList.toggle('hidden-gallery', gallery.visible === false); 12685 12686 // Update visibility toggle icon if it exists 12687 const toggleButton = li.querySelector('.visibility-toggle'); 12688 if (toggleButton) { 12689 toggleButton.classList.toggle('hidden', gallery.visible === false); 12690 } 12691 } 12692 }); 12693 12694 // Verify that the active state was actually set for the current activeGalleryId 12695 const activeElements = document.querySelectorAll('.active'); 12696 if (activeElements.length === 0 && currentActiveId) { 12697 // Only log a warning if we're not dealing with an invisible home page 12698 const activeGallery = galleries.find(g => g.id === currentActiveId); 12699 if (!(activeGallery && activeGallery.visible === false && activeGallery.isHomePage === true)) { 12700 console.warn(`No active elements found after update for ID: ${currentActiveId}`); 12701 } 12702 } 12703 12704 // Log active items for debugging 12705 console.log("Active gallery ID after update:", currentActiveId);
12706 activeElements.forEach(el => 12707 console.log("Active element:", el.textContent.trim()) 12708 ); 12709} 12710 12711 12712function debugGalleryStructure() { 12713console.group('Gallery Structure Debug'); 12714 12715// Log the raw data structure 12716console.log('Raw galleries array:', JSON.parse(JSON.stringify(galleries))); 12717 12718// Log the tree structure 12719const treeStructure = createGalleryTree(galleries); 12720 12721// Helper to print the tree 12722function printTree(items, level = 0) { 12723const indent = ' '.repeat(level); 12724items.forEach(item => { 12725 console.log(`${indent}${item.title} (ID: ${item.id}, Parent: ${item.parentId || 'ROOT'})`); 12726 if (item.children && item.children.length > 0) { 12727 printTree(item.children, level + 1); 12728 } 12729}); 12730} 12731 12732console.log('Tree structure:'); 12733printTree(treeStructure); 12734 12735// Log the DOM structure 12736console.log('DOM structure:'); 12737const treeEl = document.getElementById('galleryTree'); 12738console.log(treeEl); 12739 12740console.groupEnd(); 12741} 12742 12743function createGalleryTree(galleries) { 12744console.log("Creating gallery tree with preservation safeguards..."); 12745 12746// First, create a map of galleries by id for quick lookup 12747const galleryMap = {}; 12748const processedIds = new Set(); 12749 12750// First pass: Create clean copies without children 12751galleries.forEach(gallery => { 12752if (!gallery || !gallery.id) return; // Skip invalid galleries 12753 12754// Create a copy of the gallery object without the children field 12755const cleanGallery = { ...gallery }; 12756delete cleanGallery.children; 12757 12758// Initialize an empty children array 12759cleanGallery.children = []; 12760 12761// Add to map - even if we've seen this ID before, keep the latest version 12762if (processedIds.has(gallery.id)) { 12763 console.warn(`Multiple galleries with ID ${gallery.id} - using latest version`); 12764} 12765 12766processedIds.add(gallery.id); 12767galleryMap[gallery.id] = cleanGallery; 12768}); 12769 12770// Second pass: Assign children to their parents 12771const rootGalleries = []; 12772 12773// To detect already-processed children 12774const childrenAssigned = new Set(); 12775 12776galleries.forEach(gallery => { 12777if (!gallery || !gallery.id) return; // Skip invalid galleries 12778 12779const galleryId = gallery.id; 12780 12781// Skip if this gallery isn't in our map (could be invalid) 12782if (!galleryMap[galleryId]) return; 12783 12784// Skip if we've already assigned this gallery as a child 12785if (childrenAssigned.has(galleryId)) return; 12786 12787if (gallery.parentId && galleryMap[gallery.parentId]) { 12788 // This is a child gallery, but first check it doesn't create a circular reference 12789 12790 // Find all parents in the chain up to the root 12791 let currentParent = galleryMap[gallery.parentId]; 12792 let safeToAdd = true; 12793 let chainIds = [galleryId, gallery.parentId]; 12794 12795 while (currentParent.parentId) { 12796 // If we've seen this ID in the chain, it would create a circular reference 12797 if (chainIds.includes(currentParent.parentId)) { 12798 console.warn(`Would create circular reference for gallery ${gallery.title} - making it a root gallery`); 12799 safeToAdd = false; 12800 break; 12801 } 12802 12803 chainIds.push(currentParent.parentId); 12804 currentParent = galleryMap[currentParent.parentId]; 12805 12806 // If parent doesn't exist in the map, break the chain 12807 if (!currentParent) break; 12808 } 12809 12810 if (safeToAdd) { 12811 // Safe to add as child 12812 galleryMap[gallery.parentId].children.push(galleryMap[galleryId]); 12813 childrenAssigned.add(galleryId); 12814 } else { 12815 // Not safe - make it a root gallery 12816 rootGalleries.push(galleryMap[galleryId]); 12817 } 12818} else { 12819 // This is a root gallery (no parent or parent doesn't exist) 12820 if (!childrenAssigned.has(galleryId)) { 12821 rootGalleries.push(galleryMap[galleryId]); 12822 } 12823} 12824}); 12825 12826// Final verification 12827const allGalleryIds = new Set(galleries.map(g => g.id)); 12828const treeGalleryIds = new Set(); 12829 12830// Function to collect all IDs in the tree 12831const collectIds = (items) => { 12832items.forEach(item => { 12833 treeGalleryIds.add(item.id); 12834 if (item.children && item.children.length > 0) { 12835 collectIds(item.children); 12836 } 12837}); 12838}; 12839 12840collectIds(rootGalleries); 12841 12842// Check if any galleries are missing from the tree 12843const missingIds = []; 12844allGalleryIds.forEach(id => { 12845if (!treeGalleryIds.has(id)) { 12846 missingIds.push(id); 12847} 12848}); 12849 12850if (missingIds.length > 0) { 12851console.warn(`${missingIds.length} galleries are missing from the tree structure`); 12852 12853// Add the missing galleries as root items 12854missingIds.forEach(id => { 12855 const gallery = galleryMap[id]; 12856 if (gallery && !childrenAssigned.has(id)) { 12857 console.log(`Adding missing gallery to root: ${gallery.title}`); 12858 rootGalleries.push(gallery); 12859 } 12860}); 12861} 12862 12863console.log(`Created tree with ${rootGalleries.length} root galleries`); 12864return rootGalleries; 12865} 12866 12867function alignFolderStates() { 12868 console.log("Aligning folder data states with collapsed visual appearance"); 12869 let changed = 0; 12870
12871 galleries.forEach(gallery => { 12872 if ((gallery.isFolder === true || gallery.isSubmenu === true) && gallery.isCollapsed === false) { 12873 gallery.isCollapsed = true; 12874 changed++; 12875 } 12876 }); 12877 12878} 12879 12880 12881 12882function renderGalleryItem(gallery, level = 0, maxLevel = 10) { 12883 // Prevent infinite recursion by limiting depth 12884 if (level >= maxLevel) { 12885 console.warn(`Maximum nesting level reached for gallery: ${gallery.title}`); 12886 return ''; 12887 } 12888 12889 // Add debug info to console 12890 console.log(`Rendering gallery item: ${gallery.title}, isEditing=${isEditing}, level=${level}`); 12891 12892 // Skip hidden items in view mode (but not spacers) 12893 if (!isEditing && gallery.visible === false && !gallery.isSpacer) { 12894 return ''; 12895 } 12896 12897const isPage = isPageMenuItem(gallery); 12898const isFolder = gallery.isFolder === true || gallery.isSubmenu === true; // Support both folder and legacy submenu 12899const isNavigableParent = isNavigableMenuParent(gallery); 12900const isSpacer = gallery.isSpacer === true; 12901const isExternal = gallery.isExternal === true; 12902 12903// CRITICAL FIX: Ensure children are always populated for folders 12904// If gallery.children is missing or empty, but this is a folder, build children from flat galleries array 12905// This ensures the nested <ol> is always rendered with children in the DOM, even in view mode 12906if (isFolder) { 12907 // Always ensure children array exists 12908 if (!gallery.children) { 12909 gallery.children = []; 12910 } 12911 // If children array is empty, build it from flat galleries array using parentId 12912 if (gallery.children.length === 0) { 12913 const childrenFromFlat = galleries.filter(g => g.parentId === gallery.id); 12914 if (childrenFromFlat.length > 0) { 12915 gallery.children = childrenFromFlat; 12916 console.log(`[renderGalleryItem] Built children for folder ${gallery.title}: ${gallery.children.length} children`); 12917 } 12918 } 12919} 12920 12921// Parent-capable items need their chevron and empty nested drop zone immediately, 12922// before the first child has been added. 12923const hasChildren = isFolder || isNavigableParent || gallery.children?.length > 0; 12924const hasChildrenClass = hasChildren ? 'has-children' : ''; 12925 12926// Standard collapsed state check 12927const isCollapsed = gallery.isCollapsed === true; 12928 12929// Simple expanded class logic - if the folder is not collapsed, add 'expanded' class 12930const isExpandedClass = (isFolder || isNavigableParent) && !isCollapsed ? 'expanded' : ''; 12931 12932// Add additional classes for special types 12933const spacerClass = isSpacer ? 'spacer-item' : ''; 12934const externalClass = isExternal ? 'external-link' : ''; 12935const folderClass = isFolder ? 'folder-item' : ''; 12936const navigableParentClass = isNavigableParent ? 'navigable-parent-item' : ''; 12937 12938 const duplicateIconSvg = ` 12939 <svg xmlns="[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)" width="24" height="24" viewBox="0 0 24 24"> 12940 <g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"> 12941 <path d="M16.964 8.982c-.003-2.95-.047-4.478-.906-5.524a4 4 0 0 0-.553-.554C14.4 2 12.76 2 9.48 2s-4.92 0-6.024.905a4 4 0 0 0-.553.554C1.998 4.56 1.998 6.2 1.998 9.48s0 4.92.906 6.023q.25.304.553.553c1.047.86 2.575.904 5.525.906"/> 12942 <path d="m14.028 9.025l2.966-.043m-2.98 13.02l2.966-.043m4.992-7.937l-.028 2.96M9.01 14.036l-.028 2.96m2.505-7.971c-.832.149-2.17.302-2.477 2.024m10.485 10.91c.835-.137 2.174-.27 2.508-1.986M19.495 9.025c.832.149 2.17.302 2.477 2.024M11.5 21.957c-.833-.148-2.17-.301-2.478-2.023"/> 12943 </g> 12944 </svg> 12945 `; 12946 12947 12948 12949 // NEW: Add nesting level as a data attribute for styling 12950 const nestingLevelAttr = `data-nesting-level="${level}"`; 12951 12952 // Accessible href for SPA items (loadPage/loadGallery still preventDefault) 12953 const menuHref = gallery.isHomePage 12954 ? '/' 12955 : (gallery.url && String(gallery.url).startsWith('/') 12956 ? gallery.url 12957 : (gallery.slug ? ('/' + gallery.slug) : '#')); 12958 const childrenListId = 'gallery-children-' + gallery.id; 12959 const folderExpandedAttr = !isCollapsed ? 'true' : 'false'; 12960 const ariaCurrentAttr = (gallery.id === activeGalleryId && !isFolder && !isSpacer) 12961 ? ' aria-current="page"' 12962 : ''; 12963 12964 // Create the gallery item HTML with enhanced nesting indicators 12965 const html = ` 12966<li data-id="${gallery.id}" 12967 ${nestingLevelAttr} 12968 class="${gallery.id === activeGalleryId ? 'active' : ''} 12969 ${gallery.visible === false ? 'hidden-gallery' : ''} 12970 ${hasChildrenClass} ${isExpandedClass} ${spacerClass} ${externalClass} ${folderClass} ${navigableParentClass}">
12971 <div class="gallery-item-content nested-sortable-handle"> 12972 ${hasChildren ? ` 12973 <button type="button" class="submenu-toggle" onclick="toggleFolder(${gallery.id}, event)" aria-expanded="${folderExpandedAttr}" aria-controls="${childrenListId}" aria-label="Expand or collapse ${String(gallery.title || 'folder').replace(/"/g, '"')}"> 12974 <svg class="icon toggle-icon ${!isCollapsed ? 'rotated' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"> 12975 <polyline points="9 6 15 12 9 18"></polyline> 12976 </svg> 12977 </button> 12978 ` : ''} 12979 12980 <!-- Menu item with different rendering based on type --> 12981 ${isSpacer ? ` 12982 <!-- Spacer with non-breaking space to ensure height --> 12983 <span class="menu-item spacer" aria-hidden="true"> </span> 12984 ` : isExternal ? ` 12985 <!-- External URL uses an anchor tag with target="_blank" --> 12986 <a href="${gallery.url}" class="menu-item external-link" target="_blank" rel="noopener noreferrer"${ariaCurrentAttr}> 12987 ${gallery.title} 12988 </a> 12989 ` : isFolder ? ` 12990 <!-- Folder item with toggle functionality (button for keyboard / SR) --> 12991 <button type="button" class="menu-item folder" data-folder="true" 12992 onclick="toggleFolder(${gallery.id}, event)" aria-expanded="${folderExpandedAttr}" aria-controls="${childrenListId}"> 12993 ${gallery.title} 12994 </button> 12995 ` : ` 12996 <!-- Real link for SR/keyboard; SPA handlers keep click behavior --> 12997 <a href="${menuHref}" class="menu-item" onclick="${isPage ? `loadPage(${gallery.id}, event)` : `loadGallery(${gallery.id}, event)`}"${ariaCurrentAttr}> 12998 ${gallery.title} 12999 </a> 13000 `} 13001 13002<div class="controls"> 13003 ${!isSpacer ? ` 13004 <button class="btn visibility-toggle ${gallery.visible === false ? 'hidden' : ''}" onclick="toggleVisibility(${gallery.id}, event)"> 13005 <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"> 13006 ${gallery.visible !== false ? 13007 '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle>' : 13008 '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line>' 13009 } 13010 </svg> 13011 </button> 13012 13013 <button class="btn password-toggle ${gallery.passwordProtected ? 'protected' : ''}" onclick="toggleGalleryPasswordProtection(${gallery.id}, event)" title="${gallery.passwordProtected ? 'Remove Password Protection' : 'Add Password Protection'}"> 13014 <svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24"> 13015 ${gallery.passwordProtected ? 13016 '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M12 16.5v-2m-7.732 4.345c.225 1.67 1.608 2.979 3.292 3.056c1.416.065 2.855.099 4.44.099s3.024-.034 4.44-.1c1.684-.076 3.067-1.385 3.292-3.055c.147-1.09.268-2.207.268-3.345s-.121-2.255-.268-3.345c-.225-1.67-1.608-2.979-3.292-3.056A95 95 0 0 0 12 9c-1.585 0-3.024.034-4.44.1c-1.684.076-3.067 1.385-3.292 3.055C4.12 13.245 4 14.362 4 15.5s.121 2.255.268 3.345"/><path d="M7.5 9V6.5a4.5 4.5 0 0 1 9 0V9"/></g>' : 13017 '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 16.5v-2m-7.732 4.345c.225 1.67 1.608 2.979 3.292 3.056c1.416.065 2.855.099 4.44.099s3.024-.034 4.44-.1c1.684-.076 3.067-1.385 3.292-3.055c.147-1.09.268-2.207.268-3.345s-.121-2.255-.268-3.345c-.225-1.67-1.608-2.979-3.292-3.056A95 95 0 0 0 12 9c-1.585 0-3.024.034-4.44.1c-1.684.076-3.067 1.385-3.292 3.055C4.12 13.245 4 14.362 4 15.5s.121 2.255.268 3.345M7.5 9V6.5A4.5 4.5 0 0 1 12 2c1.96 0 3.5 1.5 4 3" color="currentColor"/>' 13018 } 13019 </svg> 13020 </button> 13021 13022 <button class="btn edit-btn" onclick="editGallery(${gallery.id}, event)">
13023 <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"> 13024 <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path> 13025 <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path> 13026 </svg> 13027 </button> 13028 ` : ''} 13029 13030 <button class="btn duplicate-btn" onclick="duplicateGalleryItem(${gallery.id}, event)" title="Duplicate"> 13031 ${duplicateIconSvg} 13032 </button> 13033 13034 13035 <button class="btn delete-btn" onclick="deleteGallery(${gallery.id}, event)"> 13036 <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"> 13037 <polyline points="3 6 5 6 21 6"></polyline> 13038 <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path> 13039 </svg> 13040 </button> 13041${!isSpacer ? ` 13042<!-- Add home toggle button --> 13043<button class="btn home-toggle ${gallery.isHomePage ? 'active' : ''}" onclick="toggleHome(${gallery.id}, event)" title="${gallery.isHomePage ? 'Currently set as Home page' : 'Set as Home page'}"> 13044 <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"> 13045 ${gallery.isHomePage ? 13046 '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m3.966 5.798l.603-1.401c.501-1.163.751-1.745 1.242-2.07C6.302 2 6.927 2 8.177 2h7.646c1.25 0 1.875 0 2.366.326c.49.326.741.908 1.242 2.07l.603 1.402c.817 1.896 1.225 2.844.788 3.523S19.366 10 17.328 10H6.671c-2.037 0-3.056 0-3.493-.679s-.029-1.627.788-3.523M4.5 10v5c0 1.886 0 2.828.586 3.414c.471.472 1.174.564 2.414.582M19.5 10v5c0 1.886 0 2.828-.586 3.414c-.471.472-1.174.564-2.414.582M13 14l-2.594 3.067c-.317.41-.475.616-.377.775c.098.158.383.158.954.158h2.034c.57 0 .856 0 .954.158c.098.159-.06.364-.377.775L10.984 22M10 2l-1 8m5-8l1 8M4 6h16" color="currentColor"/>' : 13047 '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M12 17h.009M20 8.5v5c0 3.771 0 5.657-1.172 6.828S15.771 21.5 12 21.5s-5.657 0-6.828-1.172S4 17.271 4 13.5v-5"/><path d="m22 10.5l-4.343-4.165C14.99 3.778 13.657 2.5 12 2.5S9.01 3.778 6.343 6.335L2 10.5"/></g>' 13048 } 13049 </svg> 13050</button> 13051` : ''} 13052 </div> 13053 </div> 13054 <ol id="${childrenListId}" class="nested-sortable ${level > 0 ? 'nested-level-' + level : ''}"> 13055 ${gallery.children && gallery.children.length > 0 ? 13056 gallery.children.map(child => renderGalleryItem(child, level + 1, maxLevel)).join('') : ''} 13057 </ol> 13058</li> 13059`; 13060 13061 return html; 13062} 13063 13064// Additional helper function to add visual indicators during drag operations 13065function addDragVisualFeedback() { 13066 // Add event listener for drag operations 13067 document.addEventListener('dragover', function(e) { 13068 // Find the closest nested sortable list 13069 const nearestList = e.target.closest('.nested-sortable'); 13070 if (!nearestList) return; 13071 13072 // Remove active-drop-target class from all lists 13073 document.querySelectorAll('.active-drop-target').forEach(el => { 13074 el.classList.remove('active-drop-target'); 13075 }); 13076 13077 // Add it to the current list 13078 nearestList.classList.add('active-drop-target'); 13079 13080 // Calculate if this would be a nested position 13081 // This is a simplified calculation - SortableJS has its own logic, 13082 // but this helps provide additional visual feedback 13083 const rect = nearestList.getBoundingClientRect(); 13084 const distanceFromLeft = e.clientX - rect.left; 13085 13086 // If we're close to the left edge of a nested list, add a class 13087 if (distanceFromLeft < 30) { 13088 nearestList.classList.add('potential-parent'); 13089 } else { 13090 nearestList.classList.remove('potential-parent'); 13091 } 13092 }); 13093 13094 // Remove classes when drag ends 13095 document.addEventListener('dragend', function() { 13096 document.querySelectorAll('.active-drop-target, .potential-parent').forEach(el => { 13097 el.classList.remove('active-drop-target', 'potential-parent'); 13098 }); 13099 }); 13100} 13101 13102// Make sure the addDragVisualFeedback function is called when the document is ready 13103document.addEventListener('DOMContentLoaded', function() { 13104 // Initialize the visual feedback helper 13105 addDragVisualFeedback(); 13106 13107 // If we're in edit mode, make sure the nested sortables are initialized 13108 if (document.body.classList.contains('edit-mode-active')) { 13109 setTimeout(function() { 13110 initializeNestedSortables(); 13111 }, 300); 13112 } 13113}); 13114 13115// Update the toggleEditMode function to ensure proper initialization of sortables 13116const originalToggleEditMode = window.toggleEditMode; 13117window.toggleEditMode = function() { 13118 originalToggleEditMode.apply(this, arguments); // Call original function 13119 13120 stopAutoAdvanceTimer(); 13121 13122 const sidebar = document.querySelector('.sidebar');
13123 const editControls = document.querySelector('.edit-controls'); 13124 13125 if (currentMenuLayout === 'horizontal') { 13126 if (document.body.classList.contains('edit-mode-active')) { 13127 // In horizontal layout and edit mode is ON: show sidebar and edit controls 13128 if(sidebar) sidebar.style.display = 'block'; // Or 'flex' if it's a flex container 13129 if(editControls) editControls.style.display = 'flex'; 13130 // Re-render the sidebar menu for editing 13131 renderGalleries(); 13132 } else { 13133 // In horizontal layout and edit mode is OFF: hide sidebar 13134 if(sidebar) sidebar.style.display = 'none'; 13135 // Re-render the horizontal menu for viewing 13136 renderHorizontalMenu(); 13137 } 13138 } else { 13139 // For other layouts (sidebar, top), ensure sidebar is visible 13140 if(sidebar) sidebar.style.display = 'block'; // Or 'flex' 13141 } 13142}; 13143 13144function verifyGalleryStructure() { 13145console.log("Verifying gallery structure integrity..."); 13146 13147// Count number of galleries 13148const galleryCount = galleries.length; 13149console.log(`Total galleries in flat array: ${galleryCount}`); 13150 13151// Create tree and count galleries in tree 13152const tree = createGalleryTree([...galleries]); 13153let treeCount = 0; 13154 13155function countGalleriesInTree(items) { 13156items.forEach(item => { 13157 treeCount++; 13158 if (item.children && item.children.length > 0) { 13159 countGalleriesInTree(item.children); 13160 } 13161}); 13162} 13163 13164countGalleriesInTree(tree); 13165console.log(`Total galleries in tree structure: ${treeCount}`); 13166 13167if (treeCount !== galleryCount) { 13168console.warn(`GALLERY COUNT MISMATCH: ${galleryCount} in array vs ${treeCount} in tree`); 13169return false; 13170} else { 13171console.log("Gallery structure is consistent"); 13172return true; 13173} 13174} 13175 13176function renderGalleries() { 13177console.log("Rendering galleries..."); 13178 13179 // Align with the explicit edit toggle only (not admin login / stale sidebar.editing) 13180 if (document.body.classList.contains('edit-mode-active')) { 13181 isEditing = true; 13182 } else if (typeof window.isEditing === 'boolean') { 13183 isEditing = window.isEditing; 13184 } 13185 13186 var galleriesSource = galleries; 13187 if (window.HelpSystem && typeof window.HelpSystem.getRenderGalleries === 'function') { 13188 var helpList = window.HelpSystem.getRenderGalleries(); 13189 if (helpList) galleriesSource = helpList; 13190 } 13191 13192try { 13193// Only fix circular references if absolutely necessary 13194const foundCircular = fixCircularReferences(); 13195 13196// Only create a new tree if we had to fix circular references 13197let galleryTree; 13198if (foundCircular) { 13199 console.log("Creating fresh gallery tree after fixing circular references"); 13200 galleryTree = createGalleryTree(galleriesSource); 13201} else { 13202 console.log("Using existing structure to create gallery tree"); 13203 galleryTree = createGalleryTree(galleriesSource); 13204} 13205 13206// Render the tree - IMPORTANT: Add null check 13207const tree = document.getElementById('galleryTree'); 13208if (!tree) { 13209 console.warn('galleryTree element not found, cannot render galleries'); 13210 return; // Exit the function if the element doesn't exist 13211} 13212 13213// Render the tree with maximum depth protection 13214tree.innerHTML = ` 13215 <ol class="nested-sortable"> 13216 ${galleryTree.map(gallery => renderGalleryItem(gallery, 0, 20)).join('')} 13217 </ol> 13218`; 13219 13220// Initialize sortable for all nested sortable elements 13221if (isEditing) { 13222 // Use requestAnimationFrame to ensure DOM is fully updated before initializing sortables 13223 // This prevents drag issues when items are added and immediately dragged 13224 requestAnimationFrame(() => { 13225 initializeNestedSortables(); 13226 }); 13227} 13228 13229console.log("Galleries rendered successfully"); 13230 13231// Set up folder toggle handler after rendering (works in both view and edit modes) 13232setupFolderToggleHandler(); 13233 13234// Verify structure integrity after rendering 13235verifyGalleryStructure(); 13236 13237if (typeof syncSeoNavA11y === 'function') syncSeoNavA11y(); 13238} catch (error) { 13239console.error("Error rendering galleries:", error); 13240} 13241} 13242 13243/** Prefer visible menu links for AT; keep SEO nav in DOM for crawlers. */ 13244function syncSeoNavA11y() { 13245 const seoNav = document.querySelector('.hydra-seo-nav'); 13246 if (!seoNav) return; 13247 const hasVisibleLinks = 13248 document.querySelectorAll('#galleryTree a.menu-item').length > 0 ||
13249 document.querySelectorAll('.horizontal-menu-container a.horizontal-menu-label').length > 0; 13250 const hideFromAt = hasVisibleLinks; 13251 seoNav.setAttribute('aria-hidden', hideFromAt ? 'true' : 'false'); 13252 // aria-hidden alone is not enough â focusable descendants must leave the tab order 13253 seoNav.querySelectorAll('a, button, [tabindex]').forEach((el) => { 13254 if (hideFromAt) { 13255 if (!el.hasAttribute('data-seo-prev-tabindex')) { 13256 el.setAttribute('data-seo-prev-tabindex', el.hasAttribute('tabindex') ? el.getAttribute('tabindex') : ''); 13257 } 13258 el.setAttribute('tabindex', '-1'); 13259 } else { 13260 const prev = el.getAttribute('data-seo-prev-tabindex'); 13261 if (prev === null) return; 13262 if (prev === '') el.removeAttribute('tabindex'); 13263 else el.setAttribute('tabindex', prev); 13264 el.removeAttribute('data-seo-prev-tabindex'); 13265 } 13266 }); 13267} 13268 13269function getMenuItemHref(galleryItem) { 13270 if (!galleryItem) return '#'; 13271 if (galleryItem.isExternal && galleryItem.url) return galleryItem.url; 13272 if (galleryItem.isHomePage) return '/'; 13273 if (galleryItem.url && String(galleryItem.url).startsWith('/')) return galleryItem.url; 13274 if (galleryItem.slug) return '/' + galleryItem.slug; 13275 return '#'; 13276} 13277 13278 13279function fixCircularReferences() { 13280console.log("Selectively checking for circular references..."); 13281 13282// Only process galleries that need checking: 13283// 1. Imported galleries (they're new and might cause issues) 13284// 2. Galleries with extremely deep nesting 13285// 3. Actual circular references 13286 13287const processed = new Set(); 13288 13289// First pass: Identify actual circular references without modifying structure 13290const circularIds = new Set(); 13291const suspiciousIds = new Set(); 13292 13293galleries.forEach(gallery => { 13294if (!gallery || !gallery.id) return; 13295 13296// Skip galleries we've already processed 13297if (processed.has(gallery.id)) return; 13298 13299// Check for self-reference - this is always wrong 13300if (gallery.parentId === gallery.id) { 13301 console.warn(`Self-reference detected: Gallery ${gallery.title} (${gallery.id}) is its own parent`); 13302 circularIds.add(gallery.id); 13303 return; 13304} 13305 13306// Skip galleries without parents 13307if (!gallery.parentId) { 13308 processed.add(gallery.id); 13309 return; 13310} 13311 13312// Follow the parent chain to detect circular references 13313const parentChain = [gallery.id]; 13314let currentId = gallery.parentId; 13315let loopCount = 0; 13316let foundCircular = false; 13317 13318while (currentId && loopCount < 20) { // Reasonable limit to prevent infinite loops 13319 loopCount++; 13320 13321 // If we've seen this ID before, it's a circular reference 13322 if (parentChain.includes(currentId)) { 13323 console.warn(`Circular reference detected in chain for gallery: ${gallery.title}`); 13324 circularIds.add(gallery.id); 13325 foundCircular = true; 13326 break; 13327 } 13328 13329 parentChain.push(currentId); 13330 13331 // Move up to the next parent 13332 const parent = galleries.find(g => g.id === currentId); 13333 currentId = parent?.parentId; 13334 13335 // If we can't find the parent, we can stop 13336 if (!parent) break; 13337} 13338 13339// If we hit our loop limit but didn't find a circular reference, 13340// it's suspicious but not definitely circular 13341if (loopCount >= 20 && !foundCircular) { 13342 console.warn(`Suspiciously deep parent chain for gallery ${gallery.title}, marking for review`); 13343 suspiciousIds.add(gallery.id); 13344} 13345 13346// Mark this gallery and all its ancestors as processed 13347parentChain.forEach(id => processed.add(id)); 13348}); 13349 13350// Second pass: Only fix the identified circular references 13351if (circularIds.size > 0) { 13352console.log(`Fixing ${circularIds.size} confirmed circular references`); 13353 13354galleries.forEach(gallery => { 13355 if (circularIds.has(gallery.id)) { 13356 console.log(`Breaking circular reference for gallery: ${gallery.title}`); 13357 gallery.parentId = null; 13358 } 13359}); 13360} else { 13361console.log("No circular references found"); 13362} 13363 13364// For suspicious galleries, don't automatically fix them 13365if (suspiciousIds.size > 0) { 13366console.warn(`Found ${suspiciousIds.size} galleries with unusually deep nesting - not fixing automatically`); 13367} 13368 13369return circularIds.size > 0; // Return true if we fixed anything 13370} 13371 13372// Deep linking implementation that uses the existing slug property 13373 13374// Function to get the slug for a gallery 13375function getGallerySlug(gallery) { 13376// First try to use the existing slug property 13377if (gallery.slug) { 13378console.log(`Using existing gallery slug: ${gallery.slug}`); 13379return gallery.slug;
13380} 13381 13382// If no slug exists, generate one from the title as fallback 13383if (gallery.title) { 13384const generatedSlug = gallery.title 13385 .toLowerCase() 13386 .replace(/[^ws-]/g, '') 13387 .replace(/s+/g, '-') 13388 .replace(/--+/g, '-') 13389 .trim(); 13390 13391console.log(`Generated slug from title: ${generatedSlug}`); 13392return generatedSlug || 'gallery'; 13393} 13394 13395// Default fallback 13396console.warn('Gallery has no slug or title, using default'); 13397return 'gallery'; 13398} 13399 13400// Function to update the URL when a gallery is loaded 13401// Update the URL when a gallery is loaded 13402function updateURLWithGallerySlug(gallery) { 13403 if (!gallery) return; 13404 13405 // Keep bookmarkable /_manage URL â do not rewrite to / or /slug when loading galleries/pages 13406 const __hydraPath = window.location.pathname; 13407 if (__hydraPath === '/_manage' || __hydraPath === '/_manage/') { 13408 console.log('[updateURLWithGallerySlug] On /_manage editor path; skipping slug URL update.'); 13409 return; 13410 } 13411 13412 // Get the slug for this gallery/page 13413 let slug = gallery.slug; 13414 13415 if (!slug && gallery.title) { 13416 slug = gallery.title.toLowerCase().replace(/s+/g, '-').replace(/[^w-]+/g, '').replace(/--+/g, '-').trim(); 13417 } 13418 13419 // Check if we're in preview mode (add this new code) 13420 const isPreviewMode = window.location.hostname === 'preview.neonsky.app'; 13421 13422 // Check if current history state already has this galleryId 13423 const currentState = window.history.state; 13424 const currentPath = window.location.pathname; 13425 const isAlreadyCurrentPage = currentState && currentState.galleryId === gallery.id && 13426 ((isPreviewMode && currentPath.includes(slug)) || (!isPreviewMode && currentPath === '/' + slug)); 13427 13428 if (isPreviewMode) { 13429 // Extract GUID from current URL path - first segment after domain 13430 const pathParts = window.location.pathname.split('/').filter(Boolean); 13431 const siteGuid = pathParts[0]; 13432 13433 // Create history entry if we have a valid GUID, and it's not already the current state. 13434 // Home pages use /{guid}/ (canonical preview home), matching live's '/' special-case. 13435 if (siteGuid && (gallery.isHomePage || slug)) { 13436 const targetPath = gallery.isHomePage ? ('/' + siteGuid + '/') : ('/' + siteGuid + '/' + slug); 13437 const alreadyAtPreviewHome = gallery.isHomePage && ( 13438 currentPath === '/' + siteGuid || currentPath === '/' + siteGuid + '/' 13439 ); 13440 // Only push if URL is different OR if state doesn't match (allows multiple navigations) 13441 if ((!alreadyAtPreviewHome && currentPath !== targetPath) || !isAlreadyCurrentPage) { 13442 console.log('ð [updateURLWithGallerySlug] Updating URL to ' + targetPath + ' (preview mode), galleryId: ' + gallery.id); 13443 13444 // Update browser URL without reloading, preserving the GUID 13445 window.history.pushState( 13446 { galleryId: gallery.id }, 13447 gallery.title || 'Gallery', 13448 targetPath 13449 ); 13450 13451 // Update page title 13452 document.title = formatGalleryDocumentTitle(gallery.title); 13453 } else { 13454 console.log('ð [updateURLWithGallerySlug] URL and state already match, skipping history update (preview mode)'); 13455 } 13456 } 13457 13458 return; // Exit early, we've handled the preview case 13459 } 13460 13461 // Regular (non-preview) URL handling 13462 if (slug) { 13463 // If this gallery is the designated home page, keep the URL as '/' (canonical home URL) 13464 const targetPath = gallery.isHomePage ? '/' : '/' + slug; 13465 // Only push if URL is different OR if state doesn't match (allows multiple navigations) 13466 if (currentPath !== targetPath || !isAlreadyCurrentPage) { 13467 console.log('ð [updateURLWithGallerySlug] Updating URL to ' + targetPath + ', galleryId: ' + gallery.id); 13468 window.history.pushState( 13469 { galleryId: gallery.id }, 13470 gallery.title || 'Gallery', 13471 targetPath 13472 ); 13473 document.title = formatGalleryDocumentTitle(gallery.title); 13474 } else { 13475 console.log('ð [updateURLWithGallerySlug] URL and state already match, skipping history update'); 13476 } 13477 } 13478} 13479 13480 13481function loadHomePage() { 13482 // Find a gallery marked as home page (regardless of visibility) 13483 const homePage = galleries.find(gallery => gallery.isHomePage === true); 13484 13485 if (homePage) {
13486 console.log(`Loading home page: ${homePage.title} (Visible: ${homePage.visible !== false})`); 13487 13488 // Update active gallery ID 13489 activeGalleryId = homePage.id; 13490 13491 // Add a special flag to indicate we're loading the home page 13492 // This allows us to bypass visibility checks 13493 window._loadingHomePage = true; 13494 13495 // Determine if this is a page or gallery and load appropriately 13496 if (homePage.isPage) { 13497 loadPage(homePage.id); 13498 } else { 13499 loadGallery(homePage.id); 13500 } 13501 13502 // Clear the flag after loading 13503 setTimeout(() => { 13504 window._loadingHomePage = false; 13505 }, 100); 13506 13507 // Update UI state 13508 updateActiveStates(); 13509 updateMobileTitle(); 13510 if (typeof closeMobileMenu === 'function') closeMobileMenu(); 13511 13512 return true; 13513 } 13514 13515 console.log('No home page defined'); 13516 return false; 13517} 13518 13519// 4. Update the handleURLNavigation function to check for home page 13520// Find the existing handleURLNavigation function and modify as follows: 13521// In index.ts 13522function handleURLNavigation() { 13523 const path = window.location.pathname.substring(1); // Remove leading slash 13524 const isPreviewMode = window.location.hostname === 'preview.neonsky.app'; 13525 let slug = path; 13526 let siteGuid = null; 13527 13528 // /_manage is the Hydra editor entry â treat like site root for slug resolution (not a gallery slug) 13529 if (!isPreviewMode && (path === '_manage' || path.startsWith('_manage/'))) { 13530 console.log('[handleURLNavigation] /_manage detected â using root navigation behavior'); 13531 slug = ''; 13532 } 13533 13534 if (isPreviewMode && path) { 13535 const pathParts = path.split('/'); 13536 if (pathParts.length >= 1) { 13537 siteGuid = pathParts[0]; // Path could be just GUID/ or GUID/slug 13538 } 13539 if (pathParts.length >= 2) { 13540 slug = pathParts[1]; // Slug is the part after GUID 13541 console.log(`Preview URL detected, using slug: "${slug}" under GUID: "${siteGuid}"`); 13542 } else { 13543 // Only GUID is present, or path is empty after GUID 13544 slug = ''; // Treat as root/home for the given GUID 13545 console.log(`Preview URL with only GUID: "${siteGuid}", checking for home page or default.`); 13546 } 13547 } 13548 13549 if (!slug) { // Handles root path ('/') or preview URL with only GUID ('/GUID/') 13550 console.log("No specific slug in path (root or GUID-only preview), checking for home page."); 13551 if (!loadHomePage() && galleries.length > 0) { 13552 const firstVisibleGallery = galleries.find(g => g.visible !== false && !g.isSpacer && !g.isFolder && !g.isSubmenu); 13553 if (firstVisibleGallery) { 13554 console.log("No home page, loading first visible item:", firstVisibleGallery.title); 13555 if (firstVisibleGallery.isPage) { 13556 loadPage(firstVisibleGallery.id); 13557 } else { 13558 loadGallery(firstVisibleGallery.id); 13559 } 13560 } else { 13561 console.log("No home page and no visible items to load for this path."); 13562 } 13563 } 13564 } else { 13565 console.log(`Handling URL navigation for slug: "${slug}"` + (isPreviewMode ? ` (Preview GUID: ${siteGuid})` : "")); 13566 // findGalleryByPath should be able to find the item using the slug, 13567 // handling preview mode internally if necessary or by being passed the correct slug. 13568 const matchingGallery = findGalleryByPath(slug, isPreviewMode, siteGuid); 13569 13570 if (matchingGallery) { 13571 console.log('Loading content from URL: "' + matchingGallery.title + '" (ID: ' + matchingGallery.id + ')'); 13572 console.log('ð [handleURLNavigation] Gallery properties - isPage:', matchingGallery.isPage, 'pageId:', matchingGallery.pageId, 'has pageElements:', !!(matchingGallery.pageElements && Array.isArray(matchingGallery.pageElements))); 13573 13574 if (matchingGallery.isSubmenu) { 13575 console.log('Gallery is a submenu, skipping direct load. Parent expansion might be needed.'); 13576 // Potentially, you might want to find and expand its parent here if the UI supports it. 13577 } else { 13578 // Set activeGalleryId *before* calling loadPage/loadGallery 13579 activeGalleryId = matchingGallery.id; 13580 window.activeGalleryId = matchingGallery.id; 13581 13582 // Determine if this is a page by checking multiple indicators 13583 // Check isPage flag, pageId, or pageElements array 13584 const isPage = matchingGallery.isPage === true || 13585 (matchingGallery.pageId && matchingGallery.pageId.startsWith('page_')) || 13586 (matchingGallery.pageElements && Array.isArray(matchingGallery.pageElements) && matchingGallery.pageElements.length > 0); 13587 13588 if (isPage) { 13589 console.log('ð [handleURLNavigation] Detected as PAGE, loading page: "' + matchingGallery.title + '"'); 13590 const pageLoader = typeof window.loadPage === 'function' ? window.loadPage : loadPage; 13591 pageLoader(matchingGallery.id); 13592 } else { 13593 console.log('ð [handleURLNavigation] Detected as GALLERY, loading gallery: "' + matchingGallery.title + '"'); 13594 const galleryLoader = typeof window.loadGallery === 'function' ? window.loadGallery : loadGallery; 13595 galleryLoader(matchingGallery.id, null, { forceReload: true }); 13596 } 13597 } 13598 } else { 13599 console.log(`No matching gallery found for slug: "${slug}". Checking for home page as fallback.`); 13600 if (!loadHomePage()) { // Try loading home page 13601 const firstVisibleGallery = galleries.find(g => g.visible !== false && !g.isSpacer && !g.isFolder && !g.isSubmenu); 13602 if (firstVisibleGallery) { 13603 console.log(`Loading first visible gallery instead: "${firstVisibleGallery.title}"`); 13604 if (firstVisibleGallery.isPage) { 13605 loadPage(firstVisibleGallery.id); 13606 } else { 13607 loadGallery(firstVisibleGallery.id); 13608 } 13609 } else { 13610 console.log("No matching gallery, no home page, and no visible items to load."); 13611 // Optionally, display a 404 message in the gallery-container 13612 const galleryContainer = document.querySelector('.gallery-container'); 13613 if (galleryContainer) { 13614 galleryContainer.innerHTML = '<div>Page not found.</div>'; 13615 galleryContainer.style.opacity = '1'; 13616 } 13617 } 13618 } 13619 } 13620 } 13621 13622 // Update UI states after initiating load. 13623 // These functions should ideally use the now-set activeGalleryId. 13624 if (typeof updateActiveStates === 'function') updateActiveStates(); // For sidebar/tree 13625 if (typeof updateMobileTitle === 'function') updateMobileTitle(); // For mobile header 13626 13627 // **** ADDED FOR DEEP LINKING HORIZONTAL MENU **** 13628 // Determine current layout (it might not be set by MenuStyleCustomizer yet on direct load) 13629 let currentLayout = 'sidebar'; // Default assumption 13630 if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings && window.MenuStyleCustomizer.settings.menuLayout) { 13631 currentLayout = window.MenuStyleCustomizer.settings.menuLayout; 13632 } else { 13633 // Fallback: check body class if MenuStyleCustomizer hasn't initialized settings 13634 if (document.body.classList.contains('menu-layout-horizontal')) { 13635 currentLayout = 'horizontal'; 13636 } 13637 } 13638 13639 console.log(`handleURLNavigation: Determined current layout as: ${currentLayout}`); 13640 13641 if (currentLayout === 'horizontal') { 13642 if (typeof updateActiveStatesHorizontal === 'function') { 13643 console.log("handleURLNavigation: Explicitly calling updateActiveStatesHorizontal for horizontal layout on direct navigation."); 13644 // It's possible menu items aren't rendered yet by renderHorizontalMenu if this is a very early call. 13645 // A small delay might be needed, or ensure renderHorizontalMenu has run. 13646 // For now, call it directly. If items aren't there, it won't do anything harmful. 13647 setTimeout(() => { // Add a slight delay to allow menu rendering 13648 updateActiveStatesHorizontal(); 13649 }, 100); // Adjust delay if needed, or find a more robust way to ensure menu is rendered. 13650 } else { 13651 console.warn("handleURLNavigation: updateActiveStatesHorizontal function not found for horizontal layout."); 13652 } 13653 } 13654} 13655 13656/** 13657* Enhanced loadPage function that properly cleans up gallery content 13658* @param {number} id - The page ID 13659* @param {Event} event - Optional event object 13660*/ 13661window.loadPage = function(id, event) { 13662 if (event && event.target && event.target.isConnected === false) { 13663 console.log('ð [loadPage] Ignoring navigation from detached element'); 13664 return; 13665 } 13666 if (event) { 13667 event.preventDefault(); 13668 event.stopPropagation(); 13669 const now = Date.now(); 13670 const lastCallTime = window._lastPageLoadTime || 0; 13671 window._lastPageLoadTime = now; 13672 if (now - lastCallTime < 100) {
13673 console.log('Ignoring duplicate loadPage call'); 13674 return; 13675 } 13676 } 13677 console.error('ð [loadPage] Loading page with gallery ID:', id); 13678 13679 stopAutoAdvanceTimer(); 13680 if (typeof dismissGalleryPasswordOverlay === 'function') { 13681 dismissGalleryPasswordOverlay(); 13682 } 13683 13684 let galleriesData = window.galleries || galleries; 13685 const gallery = findGalleryById(galleriesData, id); 13686 13687 if (!gallery) { 13688 console.warn('No gallery found with ID:', id, 'for page load.'); 13689 return; 13690 } 13691 13692 const contentLoadGeneration = beginHydraContentLoad('page', gallery.id, gallery.pageId || `page_${id}`); 13693 13694 window.activeGalleryId = id; 13695 activeGalleryId = id; 13696 window._activeContentType = 'page'; 13697 if (window.EnhancedGallerySystem) { 13698 window.EnhancedGallerySystem.currentGalleryId = null; 13699 } 13700 13701 if (typeof window.removeGalleryScriptsWithPause === 'function') { 13702 window.removeGalleryScriptsWithPause(); 13703 } 13704 13705 const galleryContainer = document.querySelector('.gallery-container'); 13706 if (galleryContainer) { 13707 // Clear the container but ensure it's visible 13708 galleryContainer.innerHTML = ''; 13709 galleryContainer.style.display = 'block'; 13710 galleryContainer.style.visibility = 'visible'; 13711 galleryContainer.style.opacity = '1'; 13712 console.log('ð [loadPage] Cleared and ensured gallery-container is visible'); 13713 } else { 13714 console.error('ð [loadPage] gallery-container not found!'); 13715 } 13716 13717 if (!gallery.pageId) gallery.pageId = `page_${id}`; 13718 if (!gallery.isPage) gallery.isPage = true; 13719 13720 if (typeof window.syncGalleryOptionsButtonVisibility === 'function') { 13721 window.syncGalleryOptionsButtonVisibility(); 13722 } 13723 13724 if (!window._pageIdToGalleryId) window._pageIdToGalleryId = {}; 13725 window._pageIdToGalleryId[gallery.pageId] = id; 13726 13727 const seedPageElementsOntoManager = function() { 13728 if (gallery.pageElements && Array.isArray(gallery.pageElements)) { 13729 if (window.PageManager && window.PageManager.elements) { 13730 window.PageManager.elements[gallery.pageId] = [...gallery.pageElements]; 13731 } 13732 } else if (window.PageManager && window.PageManager.elements && 13733 window.PageManager.elements[gallery.pageId] && 13734 window.PageManager.elements[gallery.pageId].length > 0) { 13735 gallery.pageElements = [...window.PageManager.elements[gallery.pageId]]; 13736 } 13737 }; 13738 seedPageElementsOntoManager(); 13739 13740 const invokePageManagerLoad = function() { 13741 seedPageElementsOntoManager(); 13742 try { 13743 console.error('ð [loadPage] Calling PageManager.loadPage for pageId:', gallery.pageId); 13744 console.error('ð [loadPage] Gallery container exists:', !!galleryContainer, 'Container HTML length:', galleryContainer ? galleryContainer.innerHTML.length : 0); 13745 console.error('ð [loadPage] Page elements count:', gallery.pageElements ? gallery.pageElements.length : 0); 13746 console.error('ð [loadPage] PageManager.elements[' + gallery.pageId + '] exists:', !!(window.PageManager.elements && window.PageManager.elements[gallery.pageId])); 13747 13748 // Small delay to ensure DOM is ready, especially when navigating back via history 13749 setTimeout(() => { 13750 if (!isHydraContentLoadCurrent(contentLoadGeneration, 'page', gallery.id)) { 13751 console.log('ð [loadPage] Skipping stale delayed PageManager load for:', gallery.pageId); 13752 return; 13753 } 13754 13755 // Verify container still exists 13756 const containerCheck = document.querySelector('.gallery-container'); 13757 console.error('ð [loadPage] Container check before loadPage:', !!containerCheck, 'Same as original:', containerCheck === galleryContainer); 13758 13759 // PageManager.loadPage is async - we need to properly handle it 13760 try { 13761 const loadPromise = window.PageManager.loadPage(gallery.pageId); 13762 console.error('ð [loadPage] PageManager.loadPage called, returned:', typeof loadPromise, 'is promise:', loadPromise && typeof loadPromise.then === 'function'); 13763 13764 if (loadPromise && typeof loadPromise.then === 'function') { 13765 loadPromise.then(() => { 13766 if (!isHydraContentLoadCurrent(contentLoadGeneration, 'page', gallery.id)) { 13767 console.log('ð [loadPage] Ignoring stale PageManager completion for:', gallery.pageId); 13768 return; 13769 } 13770 13771 console.error('ð [loadPage] PageManager.loadPage promise resolved'); 13772 if (typeof window.syncGalleryOptionsButtonVisibility === 'function') { 13773 window.syncGalleryOptionsButtonVisibility(); 13774 } 13775 // Ensure container visibility after PageManager has initialized 13776 if (galleryContainer) { 13777 const pageContainer = galleryContainer.querySelector('.page-container'); 13778 if (pageContainer) { 13779 pageContainer.style.display = 'block'; 13780 pageContainer.style.visibility = 'visible'; 13781 pageContainer.style.opacity = '1'; 13782 const elementCount = pageContainer.querySelectorAll('.page-element').length; 13783 console.error('ð [loadPage] Page container found and made visible, has', elementCount, 'elements'); 13784 if (elementCount === 0) { 13785 console.error('ð [loadPage] WARNING: Page container exists but has no elements!'); 13786 console.error('ð [loadPage] PageManager.elements[' + gallery.pageId + ']:', window.PageManager.elements[gallery.pageId]); 13787 } 13788 } else { 13789 console.error('â [loadPage] Page container not found after PageManager.loadPage'); 13790 console.error('â [loadPage] Gallery container HTML:', galleryContainer.innerHTML.substring(0, 500)); 13791 console.error('â [loadPage] Gallery container children:', galleryContainer.children.length); 13792 } 13793 } else { 13794 console.error('â [loadPage] Gallery container is null after PageManager.loadPage'); 13795 } 13796 }).catch((error) => { 13797 console.error('â [loadPage] Error in PageManager.loadPage promise:', error); 13798 console.error('â [loadPage] Error stack:', error.stack); 13799 }); 13800 } else {
13801 // If loadPage doesn't return a promise, check after a delay 13802 console.error('ð [loadPage] loadPage did not return a promise, checking after delay'); 13803 setTimeout(() => { 13804 if (!isHydraContentLoadCurrent(contentLoadGeneration, 'page', gallery.id)) { 13805 console.log('ð [loadPage] Ignoring stale non-promise PageManager completion for:', gallery.pageId); 13806 return; 13807 } 13808 if (galleryContainer) { 13809 const pageContainer = galleryContainer.querySelector('.page-container'); 13810 if (pageContainer) { 13811 pageContainer.style.display = 'block'; 13812 pageContainer.style.visibility = 'visible'; 13813 pageContainer.style.opacity = '1'; 13814 console.error('ð [loadPage] Ensured page-container is visible (non-promise path)'); 13815 } else { 13816 console.error('â [loadPage] Page container not found (non-promise path)'); 13817 console.error('â [loadPage] Gallery container HTML:', galleryContainer.innerHTML.substring(0, 500)); 13818 } 13819 } 13820 }, 200); 13821 } 13822 } catch (loadError) { 13823 console.error('â [loadPage] Exception calling PageManager.loadPage:', loadError); 13824 console.error('â [loadPage] Error stack:', loadError.stack); 13825 } 13826 13827 const isCurrentlyEditing = typeof isInEditMode === 'function' ? isInEditMode() : false; 13828 if (isCurrentlyEditing && typeof window.PageManager.setEditMode === 'function') { 13829 window.PageManager.setEditMode(isCurrentlyEditing); 13830 } 13831 }, 50); // Small delay to ensure DOM is ready 13832 } catch (error) { 13833 console.error('â [loadPage] Error in loadPage function:', error); 13834 console.error('â [loadPage] Error stack:', error.stack); 13835 } 13836 }; 13837 13838 if (window.PageManager && typeof window.PageManager.loadPage === 'function') { 13839 invokePageManagerLoad(); 13840 } else { 13841 console.error('â [loadPage] PageManager not found yet â waiting for CDN proxy script'); 13842 console.error('â [loadPage] window.PageManager:', !!window.PageManager); 13843 console.error('â [loadPage] typeof loadPage:', window.PageManager ? typeof window.PageManager.loadPage : 'N/A'); 13844 whenPageManagerReady(function() { 13845 if (!isHydraContentLoadCurrent(contentLoadGeneration, 'page', gallery.id)) { 13846 console.log('ð [loadPage] Skipping stale PageManager ready retry for:', gallery.pageId); 13847 return; 13848 } 13849 console.log('â [loadPage] PageManager became available â retrying page render'); 13850 invokePageManagerLoad(); 13851 }); 13852 } 13853 13854 // Apply menu visibility 13855 const bodyEl = document.body; 13856 if (shouldApplyMenuHiddenForEmbedOrGallery(gallery)) { 13857 bodyEl.classList.add('menu-hidden-on-page'); 13858 } else { 13859 bodyEl.classList.remove('menu-hidden-on-page'); // Ensure menu is visible if in edit mode or not hidden 13860 } 13861 applyMenuOverlayClassForGallery(gallery); 13862 13863 if (typeof updateActiveStates === 'function') updateActiveStates(); 13864 if (typeof updateMobileTitle === 'function') updateMobileTitle(); 13865 if (typeof closeMobileMenu === 'function') closeMobileMenu(); 13866 // Close the gallery options panel when navigating to a different page 13867 if (typeof window.closeOptionsPanel === 'function') { 13868 window.closeOptionsPanel(); 13869 } 13870 if (typeof updateURLWithGallerySlug === 'function') updateURLWithGallerySlug(gallery); 13871 if (typeof window.syncGalleryOptionsButtonVisibility === 'function') { 13872 window.syncGalleryOptionsButtonVisibility(); 13873 } 13874 if (typeof window !== 'undefined' && typeof document !== 'undefined') { 13875 window.dispatchEvent(new CustomEvent('page-loaded', { 13876 detail: { pageId: gallery.pageId, galleryId: id } 13877 })); 13878 } 13879} 13880 13881 13882 13883/** 13884* Ensures all required gallery styles are loaded 13885*/ 13886function ensureGalleryStylesLoaded() { 13887window.ensureGalleryStylesLoaded = ensureGalleryStylesLoaded; 13888// Use Worker route for gallery CSS (avoids CDN SSL issues) 13889const galleryScriptsBase = window.location.origin + '/gallery-scripts/'; 13890const requiredStyles = [ 13891{ id: 'neon-gallery-css', href: galleryScriptsBase + 'neon-gallery-hp-hydra-v260923-002.css' }, 13892{ id: 'quill-css', href: 'https://cdn.quilljs.com/1.3.6/quill.snow.css' } 13893]; 13894console.log('Loading gallery CSS from Worker:', galleryScriptsBase + 'neon-gallery-hp-hydra-v260923-002.css'); 13895
13896requiredStyles.forEach(style => { 13897if (!document.getElementById(style.id)) { 13898 const link = document.createElement('link'); 13899 link.id = style.id; 13900 link.rel = 'stylesheet'; 13901 link.href = style.href; 13902 if (style.href.includes('cdn.neonsky.app')) { 13903 link.onerror = function() { 13904 console.error('â Gallery CSS failed, retrying via proxy:', link.href); 13905 // Set flag if not already set 13906 if (!window.useCdnProxy) { 13907 window.useCdnProxy = true; 13908 console.error('ð© CDN_PROXY_FLAG SET: Gallery CSS failed, using proxy for all subsequent requests'); 13909 } 13910 const proxyUrl = link.href.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/'); 13911 link.href = proxyUrl; 13912 link.onerror = function() { 13913 console.error('â Proxy failed, trying storage:', proxyUrl); 13914 link.href = link.href.replace('cdn.neonsky.app', 'storage.neonsky.app'); 13915 }; 13916 }; 13917 } 13918 document.head.appendChild(link); 13919 console.log(`Added gallery style: ${style.id}`); 13920} 13921}); 13922} 13923 13924 13925/** 13926* Ensures all required gallery scripts are loaded 13927* @returns {Promise} A promise that resolves when all scripts are loaded 13928*/ 13929function ensureGalleryScriptsLoaded() { 13930const requiredScripts = [ 13931{ id: 'crypto-js', src: 'https://cdn.jsdelivr.net/npm/[email protected]/crypto-js.min.js' }, 13932{ id: 'quill-js', src: 'https://cdn.quilljs.com/1.3.6/quill.min.js' }, 13933 { id: 'quill-integration', src: 'https://cdn.neonsky.app/quill-integration-v260923-002.js' } 13934]; 13935 13936const promises = requiredScripts.map(script => { 13937return new Promise((resolve, reject) => { 13938 // If script is already loaded, resolve immediately 13939 if (document.getElementById(script.id)) { 13940 resolve(); 13941 return; 13942 } 13943 13944 // Create and load the script 13945 const scriptElement = document.createElement('script'); 13946 scriptElement.id = script.id; 13947 scriptElement.src = script.src; 13948 scriptElement.onload = resolve; 13949 scriptElement.onerror = () => reject(new Error(`Failed to load script: ${script.src}`)); 13950 document.head.appendChild(scriptElement); 13951}); 13952}); 13953 13954return Promise.all(promises); 13955} 13956 13957/** 13958 * Creates a clean gallery context without using an iframe 13959 * @param {Object} gallery - The gallery object 13960 * @param {HTMLElement} container - The container to load the gallery into 13961 */ 13962function createGalleryContext(gallery, container) { 13963 console.log('Creating gallery context for:', gallery.title); 13964 13965 // CRITICAL: Ensure gallery CSS is loaded BEFORE loading the gallery script 13966 // This ensures styles are available even if NDJSON fails and falls back to classic JSON 13967 ensureGalleryStylesLoaded(); 13968 13969 // Generate the page alias safely 13970 const pageAlias = gallery.slug || gallery.title.toLowerCase().replace(/s+/g, '-'); 13971 13972 // Get our gallery options 13973 const galleryOptions = gallery.galleryOptions || {}; 13974 13975 // Get token data 13976 let authToken = null; 13977 // First try TokenManager if available 13978 if (window.TokenManager && typeof window.TokenManager.getToken === 'function') { 13979 authToken = window.TokenManager.getToken(); 13980 } 13981 // Then try global token 13982 else if (window.didToken) { 13983 authToken = window.didToken; 13984 } 13985 13986 // Set up Parameters for the gallery 13987 const galleryParameters = { 13988 SiteAlias: window.location.hostname, 13989 InitialPageUuid: gallery.pageId, 13990 InitialPageAlias: pageAlias, 13991 isInEditor: window.isEditing || false, 13992 siteId: window.siteId || '', 13993 isHydra: true 13994 }; 13995 13996 // If we have a token, add it 13997 if (authToken) { 13998 galleryParameters.hydraAuthToken = authToken; 13999 } 14000 14001 // Preserve original window.Parameters 14002 const originalParameters = window.Parameters; 14003 14004 // REMOVED: Loading indicator - no longer showing the loading animation 14005 14006 // Load the main gallery script - Use Worker route (avoids CDN SSL issues) 14007 const galleryScript = document.createElement('script'); 14008 const galleryScriptsBase = window.location.origin + '/gallery-scripts/'; 14009 galleryScript.src = galleryScriptsBase + 'neon-gallery-main-v260923-002.js'; 14010 console.log('Loading gallery-main script from Worker:', galleryScript.src); 14011 galleryScript.onerror = function() { 14012 console.error('â Gallery script failed, retrying via proxy:', galleryScript.src); 14013 const proxyUrl = galleryScript.src.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/'); 14014 galleryScript.src = proxyUrl; 14015 galleryScript.onerror = function() { 14016 console.error('â Proxy failed, trying storage:', proxyUrl); 14017 galleryScript.src = galleryScript.src.replace('cdn.neonsky.app', 'storage.neonsky.app'); 14018 }; 14019 }; 14020 14021 // Setup the gallery configuration 14022 const neonGalleryConfig = { 14023 useData: true, 14024 useCDN: true, 14025 version: 'live', 14026 manualCollectionName: "mod", 14027 layoutType: "grid", 14028 ...galleryOptions, 14029 siteId: window.siteId || '' 14030 }; 14031 14032 // Execute in a safe way that minimizes global namespace pollution 14033 try { 14034 // Set up the global variables needed by the gallery script 14035 window.Parameters = galleryParameters; 14036 window.neonGalleryConfig = neonGalleryConfig; 14037 14038 // Monitor for script load/error 14039 galleryScript.onload = () => { 14040 console.log('Gallery script loaded successfully for:', gallery.title); 14041 14042 // REMOVED: Don't need to remove the loading indicator since we don't add it 14043 }; 14044 14045 galleryScript.onerror = () => { 14046 console.error('Failed to load gallery script for:', gallery.title); 14047 //container.innerHTML = '<div class="gallery-error">Error loading gallery. Please try again later.</div>'; 14048 // Restore original parameters 14049 window.Parameters = originalParameters; 14050 }; 14051 14052 // Add the script to load the gallery 14053 document.body.appendChild(galleryScript); 14054 14055 // Set up a cleanup function that will be called when another gallery is loaded 14056 container.cleanup = () => { 14057 // Restore original parameters when cleaning up 14058 window.Parameters = originalParameters; 14059 14060 // Remove the gallery script to prevent conflicts 14061 galleryScript.remove(); 14062 14063 // Clear main gallery container 14064 if (container.parentNode) { 14065 container.innerHTML = ''; 14066 } 14067 }; 14068 } catch (error) { 14069 console.error('Error creating gallery context:', error); 14070 //container.innerHTML = '<div class="gallery-error">Error initializing gallery. Please try again later.</div>'; 14071 // Restore original parameters 14072 window.Parameters = originalParameters; 14073 } 14074} 14075 14076/** 14077* Only used for the PageManager's initializeDOM - ensures clean content swap 14078*/ 14079function cleanGalleryContainer() { 14080const galleryContainer = document.querySelector('.gallery-container'); 14081if (galleryContainer) { 14082galleryContainer.innerHTML = ''; 14083} 14084} 14085 14086 14087 14088/** 14089* Initializes the page using the PageManager 14090* To be used by PageManager.initializeDOM 14091*/ 14092function initPageContent(pageId) { 14093// First clear the container completely 14094cleanGalleryContainer(); 14095 14096// Then load the page content 14097if (window.PageManager && window.PageManager.loadPage) { 14098window.PageManager.loadPage(pageId); 14099} 14100} 14101 14102 14103/** 14104* Cleanup gallery content when changing between galleries or pages 14105*/ 14106function cleanupGalleryContent() { 14107// Get the gallery container 14108const galleryContainer = document.querySelector('.gallery-container'); 14109if (!galleryContainer) return; 14110 14111// Find the direct gallery content 14112const directContent = galleryContainer.querySelector('.gallery-direct-content'); 14113if (directContent && typeof directContent.cleanup === 'function') { 14114// Call the cleanup function 14115directContent.cleanup(); 14116} 14117 14118// Clear the container 14119galleryContainer.innerHTML = ''; 14120} 14121 14122 14123 14124 14125/** 14126* Simplified loadGallery function that follows the same content swap pattern 14127* @param {number} id - The gallery ID 14128* @param {Event} event - Optional event object 14129*/ 14130 14131// Override loadGallery to update URL 14132window.loadGallery = async function(id, event) { 14133 14134 14135// Stop event propagation if provided 14136if (event) { 14137 event.stopPropagation(); 14138} 14139 14140const galleryContainer = document.querySelector('.gallery-container'); 14141if (galleryContainer) { 14142 // Apply immediate hiding styles 14143 galleryContainer.style.opacity = '0'; 14144} 14145 14146console.log('Loading gallery with ID:', id); 14147 14148stopAutoAdvanceTimer();
14149if (typeof dismissGalleryPasswordOverlay === 'function') { 14150 dismissGalleryPasswordOverlay(); 14151} 14152 14153// Find gallery by ID 14154const gallery = findGalleryById(galleries, id); // Assuming `galleries` is accessible 14155 14156if (!gallery) { 14157 console.warn('No gallery found with ID:', id); 14158 if (galleryContainer) { 14159 galleryContainer.style.opacity = '1'; 14160 } // Show container if gallery not found 14161 return; 14162} 14163 14164// Check if this is a page, if so use loadPage instead 14165if (isPageMenuItem(gallery)) { 14166 // Use the loadPage function defined in this scope (or window.loadPage if you prefer) 14167 return loadPage(id, event); 14168} 14169 14170// Skip submenu items 14171if (gallery.isSubmenu) { 14172 console.log('Gallery is submenu, skipping URL update'); 14173 toggleSubmenu(id, event || { stopPropagation: () => {} }); // Assuming toggleSubmenu is available 14174 if (galleryContainer) { 14175 galleryContainer.style.opacity = '1'; 14176 } // Show container 14177 return; 14178} 14179 14180console.log('Found gallery:', gallery.title); 14181 14182window._activeContentType = 'gallery'; 14183beginHydraContentLoad('gallery', gallery.id, gallery.pageId); 14184if (window.PageManager && typeof window.PageManager.clearCurrentPage === 'function') { 14185 window.PageManager.clearCurrentPage(); 14186} 14187 14188// CRITICAL: Set active gallery ID consistently in both global and window scope 14189console.log('Setting active gallery ID to:', id, '(Previous value:', (window.activeGalleryId || activeGalleryId), ')'); 14190window.activeGalleryId = id; 14191activeGalleryId = id; // Ensure both variables are synchronized 14192 14193// Save reference to which gallery we're loading (for later verification) 14194window.currentLoadingGalleryId = id; 14195 14196// CRITICAL: Thorough cleanup with await to ensure completion 14197if (typeof window.removeGalleryScriptsWithPause === 'function') { 14198 await window.removeGalleryScriptsWithPause(); 14199} 14200 14201 14202// Clear all existing content from gallery container 14203if (!galleryContainer) { 14204 console.error('Gallery container not found'); 14205 return; 14206} 14207 14208// Force clear all content 14209galleryContainer.innerHTML = ''; 14210 14211 14212// CRITICAL: Double-check active gallery ID before updating UI 14213if (window.activeGalleryId !== id || activeGalleryId !== id) { 14214 console.warn('Active gallery ID changed unexpectedly before UI update, restoring to:', id); 14215 window.activeGalleryId = id; 14216 activeGalleryId = id; 14217} 14218 14219// Apply menu visibility based on the gallery's setting and edit mode 14220const bodyEl = document.body; 14221if (shouldApplyMenuHiddenForEmbedOrGallery(gallery)) { 14222 bodyEl.classList.add('menu-hidden-on-page'); 14223} else { 14224 bodyEl.classList.remove('menu-hidden-on-page'); // Ensure menu is visible if in edit mode or not hidden 14225} 14226applyMenuOverlayClassForGallery(gallery); 14227 14228// Update UI state using explicit window function references or local fallbacks 14229if (typeof window.updateActiveStates === 'function') { 14230 window.updateActiveStates(); 14231} else if (typeof updateActiveStates === 'function') { 14232 updateActiveStates(); 14233} 14234 14235if (typeof window.updateMobileTitle === 'function') { 14236 window.updateMobileTitle(); 14237} else if (typeof updateMobileTitle === 'function') { 14238 updateMobileTitle(); 14239} 14240 14241if (typeof window.closeMobileMenu === 'function') { 14242 window.closeMobileMenu(); 14243} else if (typeof closeMobileMenu === 'function') { 14244 closeMobileMenu(); 14245} 14246 14247// Update URL with gallery slug 14248if (typeof window.updateURLWithGallerySlug === 'function') { 14249 window.updateURLWithGallerySlug(gallery); 14250} else if (typeof updateURLWithGallerySlug === 'function') { 14251 updateURLWithGallerySlug(gallery); 14252} 14253 14254 // Create gallery container and load content 14255const neonGalleryContainer = document.createElement('div'); 14256neonGalleryContainer.id = 'neon-gallery-container'; 14257neonGalleryContainer.className = 'gallery-direct-content'; 14258neonGalleryContainer.setAttribute('data-gallery-id', gallery.id.toString()); 14259neonGalleryContainer.style.width = '100%'; 14260neonGalleryContainer.style.height = '100%'; 14261galleryContainer.appendChild(neonGalleryContainer); 14262 14263// Add a cache-busting parameter to ensure script is freshly loaded 14264const timestamp = Date.now(); 14265 14266// Set up gallery parameters 14267window.Parameters = { 14268 SiteAlias: window.location.hostname, 14269 InitialPageUuid: gallery.pageId, 14270 InitialPageAlias: gallery.slug || slugify(gallery.title), // Assuming slugify is available 14271 isInEditor: window.isEditing || false, // Assuming window.isEditing is available 14272 siteId: window.siteId || gallery.siteId || '', // Assuming window.siteId is available 14273 isHydra: true,
14274 galleryInstanceId: timestamp, 14275 // Added loadedGalleryId as per your original function 14276 loadedGalleryId: gallery.id 14277}; 14278 14279// Debug: Check what galleryOptions contains 14280console.error('ð INDEX.TS DEBUG - Gallery:', gallery.title, 'has isPerma:', gallery.galleryOptions?.isPerma, 'permaURL:', gallery.galleryOptions?.permaURL, 'loadTxId:', gallery.galleryOptions?.loadTxId, 'loadPermaURL:', gallery.galleryOptions?.loadPermaURL); 14281 14282// Set up gallery config 14283window.neonGalleryConfig = { 14284 useData: true, 14285 useCDN: true, 14286 version: 'live', 14287 manualCollectionName: gallery.galleryOptions?.manualCollectionName || "mod", 14288 layoutType: gallery.galleryOptions?.layoutType || "grid", 14289 // Spread gallery options AFTER setting defaults to ensure current gallery's settings take precedence 14290 // This ensures that only properties from the current gallery are included, not from previous galleries 14291 ...(gallery.galleryOptions || {}), 14292 // CRITICAL: Explicitly include Load Network fields to ensure they're passed to gallery script 14293 // These fields are required for the gallery to determine if it should use Load Network racing 14294 loadTxId: gallery.galleryOptions?.loadTxId || null, 14295 loadPermaURL: gallery.galleryOptions?.loadPermaURL || null, 14296 // Cloudflare CDN NDJSON (unique id) â when present, gallery loads CDN-first (3s) then Fly 14297 cdnNdjsonId: gallery.galleryOptions?.cdnNdjsonId || null, 14298 cdnNdjsonUrl: gallery.galleryOptions?.cdnNdjsonUrl || null, 14299 cdnNdjsonStableUrl: gallery.galleryOptions?.cdnNdjsonStableUrl || gallery.galleryOptions?.embedPointerUrl || null, 14300 embedPointerUrl: gallery.galleryOptions?.embedPointerUrl || gallery.galleryOptions?.cdnNdjsonStableUrl || null, 14301 cdnSaveTimestamp: gallery.galleryOptions?.cdnSaveTimestamp || null, 14302 siteId: window.siteId || gallery.siteId || '', // Assuming window.siteId is available 14303 // Added galleryId and galleryInstanceId as per your original function - these OVERRIDE spread 14304 galleryId: gallery.id, 14305 galleryInstanceId: timestamp 14306}; 14307 14308console.error('ð INDEX.TS DEBUG - After spread, window.neonGalleryConfig.isPerma:', window.neonGalleryConfig.isPerma, 'permaURL:', window.neonGalleryConfig.permaURL, 'loadTxId:', window.neonGalleryConfig.loadTxId, 'loadPermaURL:', window.neonGalleryConfig.loadPermaURL, 'cdnNdjsonId:', window.neonGalleryConfig.cdnNdjsonId); 14309 14310// Create and add the gallery script with cache-busting 14311// Use Worker route (avoids CDN SSL issues) 14312const script = document.createElement('script'); 14313const galleryScriptsBase = window.location.origin + '/gallery-scripts/'; 14314script.src = galleryScriptsBase + 'neon-gallery-main-v260923-002.js'; // Using versioned file 14315script.setAttribute('data-gallery-id', gallery.id.toString()); // Keep data-gallery-id attribute 14316script.setAttribute('data-timestamp', timestamp.toString()); // Keep data-timestamp attribute 14317console.log('Loading gallery-main script from Worker:', script.src); 14318script.onerror = function() { 14319 console.error('â Gallery script failed, retrying via proxy:', script.src); 14320 const proxyUrl = script.src.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/'); 14321 script.src = proxyUrl; 14322 script.onerror = function() { 14323 console.error('â Proxy failed, trying storage:', proxyUrl); 14324 script.src = script.src.replace('cdn.neonsky.app', 'storage.neonsky.app'); 14325 }; 14326}; 14327 14328// Added script.onload from your original function 14329script.onload = function() { 14330 if (window.currentLoadingGalleryId !== gallery.id) { 14331 console.warn('Gallery ID mismatch! Expected:', gallery.id, 14332 'Current:', window.currentLoadingGalleryId); 14333 } 14334 console.log(`Gallery script loaded for: ${gallery.title} (ID: ${gallery.id})`); 14335}; 14336 14337 14338if (galleryContainer) { 14339 setTimeout(() => { 14340 galleryContainer.style.opacity = '1'; 14341 }, 50); // Small delay to ensure content is ready 14342 } 14343 14344console.log(`Loading fresh gallery script for: ${gallery.title}`); 14345document.body.appendChild(script); 14346} 14347 14348// Function to ensure gallery container is visible 14349function ensureGalleryVisibility() { 14350 const galleryContainer = document.querySelector('.gallery-container'); 14351 if (galleryContainer && galleryContainer.style.opacity === '0') { 14352 console.log('ensureGalleryVisibility: Setting gallery container opacity to 1'); 14353 galleryContainer.style.opacity = '1'; 14354 } 14355} 14356 14357// Handle browser back/forward navigation 14358window.addEventListener('popstate', async function(event) { // Make it async 14359 if (window.__hydraPopstateLock) { 14360 return; 14361 } 14362 window.__hydraPopstateLock = true; 14363 try { 14364 console.error('ð [Popstate] Event triggered. State:', event.state, 'Current URL:', window.location.pathname); 14365 let galleryIdToLoad = null; 14366 let galleryToLoad = null; 14367 14368 // First, try to get galleryId from event.state 14369 if (event.state && event.state.galleryId) { 14370 galleryIdToLoad = event.state.galleryId; 14371 console.error('ð [Popstate] Found galleryId in event.state:', galleryIdToLoad); 14372 // Ensure galleries array is available and use findGalleryById helper 14373 const currentGalleries = window.galleries || galleries || []; 14374 galleryToLoad = findGalleryById(currentGalleries, galleryIdToLoad); 14375 } 14376 14377 if (window.__closingNestedLightbox) { 14378 window.__closingNestedLightbox = false;
14379 if (typeof updateActiveStates === 'function') updateActiveStates(); 14380 if (typeof updateMobileTitle === 'function') updateMobileTitle(); 14381 return; 14382 } 14383 14384 if (window.lightboxNavigationStack?.length > 0 && 14385 typeof window.closeNestedGalleryLightbox === 'function') { 14386 const stackTop = window.lightboxNavigationStack[window.lightboxNavigationStack.length - 1]; 14387 const parentId = stackTop?.parentGalleryId; 14388 if (parentId && (event.state?.galleryId === parentId || galleryToLoad?.id === parentId)) { 14389 window.closeNestedGalleryLightbox({ fromPopstate: true }); 14390 if (typeof updateActiveStates === 'function') updateActiveStates(); 14391 if (typeof updateMobileTitle === 'function') updateMobileTitle(); 14392 return; 14393 } 14394 } 14395 14396 if (typeof dismissGalleryPasswordOverlay === 'function') { 14397 dismissGalleryPasswordOverlay(); 14398 } 14399 14400 // If no gallery found from state, resolve from URL slug (handles history entries with null state) 14401 if (!galleryToLoad) { 14402 console.error('ð [Popstate] No galleryId in event.state or gallery not found, reading from URL:', window.location.pathname); 14403 const path = window.location.pathname.substring(1); 14404 const isPreviewMode = window.location.hostname === 'preview.neonsky.app'; 14405 let slug = path; 14406 let siteGuid = null; 14407 if (!isPreviewMode && (path === '_manage' || path.startsWith('_manage/'))) { 14408 slug = ''; 14409 } 14410 if (isPreviewMode && path) { 14411 const pathParts = path.split('/'); 14412 if (pathParts.length >= 1) siteGuid = pathParts[0]; 14413 if (pathParts.length >= 2) slug = pathParts[1]; 14414 else slug = ''; 14415 } 14416 if (slug && typeof findGalleryByPath === 'function') { 14417 galleryToLoad = findGalleryByPath(slug, isPreviewMode, siteGuid); 14418 if (galleryToLoad) { 14419 console.error('ð [Popstate] Resolved from URL slug: "' + galleryToLoad.title + '" (ID: ' + galleryToLoad.id + ')'); 14420 } 14421 } 14422 } 14423 14424 // If we have a gallery from state or URL, load it 14425 let contentLoaded = false; 14426 if (galleryToLoad) { 14427 console.error('ð [Popstate] Found gallery in history: "' + galleryToLoad.title + '" (ID: ' + galleryToLoad.id + ')'); 14428 console.error('ð [Popstate] Gallery properties - isPage:', galleryToLoad.isPage, 'pageId:', galleryToLoad.pageId, 'has pageElements:', !!(galleryToLoad.pageElements && Array.isArray(galleryToLoad.pageElements))); 14429 14430 activeGalleryId = galleryToLoad.id; 14431 window.activeGalleryId = galleryToLoad.id; 14432 14433 // Browser history navigation is explicit user intent â load content regardless of menu visibility. 14434 // Determine if this is a page by checking multiple indicators 14435 const isPage = galleryToLoad.isPage === true || 14436 (galleryToLoad.pageId && galleryToLoad.pageId.startsWith('page_')) || 14437 (galleryToLoad.pageElements && Array.isArray(galleryToLoad.pageElements) && galleryToLoad.pageElements.length > 0); 14438 14439 if (isPage) { 14440 console.error('ð [Popstate] Detected as PAGE, loading page "' + galleryToLoad.title + '"'); 14441 const pageLoader = typeof window.loadPage === 'function' ? window.loadPage : loadPage; 14442 if (typeof pageLoader === 'function') { 14443 console.error('ð [Popstate] Calling loadPage for ID:', galleryToLoad.id); 14444 pageLoader(galleryToLoad.id); 14445 contentLoaded = true; 14446 } else { 14447 console.error('â [Popstate] loadPage function not available'); 14448 } 14449 } else { 14450 console.error('ð [Popstate] Detected as GALLERY, loading gallery "' + galleryToLoad.title + '"'); 14451 // Clear page state left over from prior page navigation 14452 if (window.PageManager && typeof window.PageManager.clearCurrentPage === 'function') { 14453 window.PageManager.clearCurrentPage(); 14454 } 14455 window._activeContentType = 'gallery'; 14456 const galleryLoader = typeof window.loadGallery === 'function' ? window.loadGallery : loadGallery; 14457 if (typeof galleryLoader === 'function') { 14458 await galleryLoader(galleryToLoad.id, null, { forceReload: true }); 14459 contentLoaded = true; 14460 } else { 14461 console.error('â [Popstate] loadGallery function not available'); 14462 } 14463 } 14464 } else { 14465 console.error('â [Popstate] No gallery found and handleURLNavigation not available'); 14466 } 14467 14468 // Update UI states after loading (or attempting to load) content 14469 if (typeof updateActiveStates === 'function') updateActiveStates(); 14470 if (typeof updateMobileTitle === 'function') updateMobileTitle(); 14471 14472 // Ensure gallery visibility after popstate navigation 14473 setTimeout(ensureGalleryVisibility, 1000); 14474 14475 let currentLayout = 'sidebar'; 14476 if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings && window.MenuStyleCustomizer.settings.menuLayout) { 14477 currentLayout = window.MenuStyleCustomizer.settings.menuLayout; 14478 } else if (document.body.classList.contains('menu-layout-horizontal')) { 14479 currentLayout = 'horizontal'; 14480 } 14481 14482 if (currentLayout === 'horizontal') { 14483 if (typeof updateActiveStatesHorizontal === 'function') { 14484 console.log("Popstate: Calling updateActiveStatesHorizontal for horizontal layout with a delay."); 14485 setTimeout(() => { 14486 console.log("[Delayed Update from Popstate] Calling updateActiveStatesHorizontal."); 14487 updateActiveStatesHorizontal(); 14488 }, 150); // Slightly longer delay for popstate, as full content might be re-initializing 14489 } 14490 } 14491 } finally { 14492 setTimeout(function() { window.__hydraPopstateLock = false; }, 300); 14493 } 14494}); 14495 14496// Check for direct URL access on page load (fallback when bootstrap did not route) 14497document.addEventListener('DOMContentLoaded', function() { 14498setTimeout(function() { 14499console.log('Checking for deep links after DOM content loaded'); 14500var __p = window.location.pathname; 14501if (__p !== '/' && __p !== '/_manage' && __p !== '/_manage/' && !window.__hydraBootstrapRouteAttempted) { 14502 handleURLNavigation(); 14503} 14504// Ensure gallery visibility after URL navigation 14505setTimeout(ensureGalleryVisibility, 1000); 14506}, 500); 14507}); 14508 14509document.addEventListener('page-save-requested', function(e) { 14510const data = e.detail; 14511if (data && data.pageId && data.elements) { 14512// Find the gallery/page that corresponds to this pageId 14513const page = galleries.find(g => g.pageId === data.pageId); 14514if (page) { 14515 // Store the page elements 14516 page.pageElements = data.elements; 14517 14518 // Save to server 14519 saveGalleries(); 14520} 14521} 14522}); 14523 14524document.addEventListener('DOMContentLoaded', function() { 14525function preloadRichTextEditor() { 14526 if (window.RichTextEditor && typeof window.RichTextEditor._ensureEditorDependencies === 'function') { 14527 console.log('Preloading Rich Text Editor dependencies'); 14528 return window.RichTextEditor._ensureEditorDependencies() 14529 .then(() => console.log('Rich Text Editor dependencies preloaded successfully')) 14530 .catch(error => console.warn('Failed to preload Rich Text Editor:', error)); 14531 } 14532 14533 if (window.PageManager && typeof window.PageManager._ensureRichTextEditor === 'function') { 14534 return window.PageManager._ensureRichTextEditor() 14535 .catch(error => console.warn('Failed to preload Rich Text Editor via PageManager:', error)); 14536 } 14537 14538 return null; 14539} 14540 14541preloadRichTextEditor(); 14542setTimeout(preloadRichTextEditor, 1000); 14543setTimeout(preloadRichTextEditor, 3000); 14544}); 14545 14546document.addEventListener('DOMContentLoaded', function() { 14547const submenuCheck = document.getElementById('createSubmenu'); 14548const submenuTitleField = document.getElementById('submenuTitle'); 14549const submenuTitleGroup = submenuTitleField?.parentElement; 14550 14551if (submenuCheck && submenuTitleGroup) { 14552submenuCheck.addEventListener('change', function() { 14553 submenuTitleGroup.style.display = this.checked ? 'block' : 'none'; 14554}); 14555} 14556}); 14557 14558// Also check immediately if document is already loaded (fallback only â avoid duplicate loadPage vs bootstrap) 14559if (document.readyState !== 'loading') { 14560setTimeout(function() { 14561console.log('Document already loaded, checking for deep links'); 14562var __p2 = window.location.pathname; 14563if (__p2 !== '/' && __p2 !== '/_manage' && __p2 !== '/_manage/' && !window.__hydraBootstrapRouteAttempted) { 14564 handleURLNavigation(); 14565} 14566}, 300); 14567} 14568 14569// ============================================ 14570// CONCURRENT EDIT CONFLICT DETECTION 14571// ============================================ 14572(function() { 14573 // Initialize local timestamp tracking 14574 window.lastKnownConfigTimestamp = null; 14575 let conflictPollInterval = null; 14576 14577 // Initialize timestamp on page load 14578 async function initializeConfigTimestamp() { 14579 try { 14580 const timestampUrl = typeof getApiUrl === 'function' ? 14581 getApiUrl('/api/config-timestamp') : 14582 '/api/config-timestamp'; 14583 console.error('[CONFLICT DETECT] Initializing config timestamp from:', timestampUrl); 14584 const response = await fetch(timestampUrl); 14585 if (response.ok) { 14586 const data = await response.json(); 14587 if (data.lastModified) { 14588 window.lastKnownConfigTimestamp = data.lastModified; 14589 console.error('[CONFLICT DETECT] Initialized config timestamp:', window.lastKnownConfigTimestamp); 14590 } else { 14591 console.error('[CONFLICT DETECT] No lastModified in response:', data); 14592 } 14593 } else { 14594 console.error('[CONFLICT DETECT] Failed to initialize timestamp, status:', response.status); 14595 } 14596 } catch (error) { 14597 console.error('[CONFLICT DETECT] Could not initialize config timestamp:', error); 14598 } 14599 } 14600
14601 // Check for conflicts by polling server 14602 async function checkForConflicts() { 14603 // Only poll when in edit mode 14604 if (!document.body.classList.contains('edit-mode-active')) { 14605 console.error('[CONFLICT DETECT] Skipping poll - not in edit mode'); 14606 return; 14607 } 14608 14609 try { 14610 const timestampUrl = typeof getApiUrl === 'function' ? 14611 getApiUrl('/api/config-timestamp') : 14612 '/api/config-timestamp'; 14613 console.error('[CONFLICT DETECT] Polling for conflicts from:', timestampUrl); 14614 console.error('[CONFLICT DETECT] Current local timestamp:', window.lastKnownConfigTimestamp); 14615 14616 const response = await fetch(timestampUrl); 14617 14618 if (response.ok) { 14619 const data = await response.json(); 14620 const serverTimestamp = data.lastModified; 14621 console.error('[CONFLICT DETECT] Server timestamp:', serverTimestamp); 14622 14623 // If we have a local timestamp and it differs from server, show conflict 14624 if (window.lastKnownConfigTimestamp !== null && 14625 serverTimestamp !== null && 14626 serverTimestamp !== window.lastKnownConfigTimestamp) { 14627 console.error('[CONFLICT DETECT] â ï¸ CONFLICT DETECTED! Local:', window.lastKnownConfigTimestamp, 'Server:', serverTimestamp); 14628 showConflictIndicator(); 14629 } else { 14630 console.error('[CONFLICT DETECT] No conflict - timestamps match or null'); 14631 hideConflictIndicator(); 14632 } 14633 } else { 14634 console.error('[CONFLICT DETECT] Poll failed with status:', response.status); 14635 } 14636 } catch (error) { 14637 console.error('[CONFLICT DETECT] Error checking for conflicts:', error); 14638 } 14639 } 14640 14641 // Show conflict indicator icon 14642 function showConflictIndicator() { 14643 const indicator = document.getElementById('conflictIndicator'); 14644 console.error('[CONFLICT DETECT] Showing conflict indicator, element found:', !!indicator); 14645 if (indicator) { 14646 indicator.style.display = 'flex'; 14647 indicator.style.visibility = 'visible'; 14648 indicator.style.opacity = '1'; 14649 indicator.style.background = '#d32f2f'; 14650 indicator.style.borderColor = '#d32f2f'; 14651 console.error('[CONFLICT DETECT] Conflict indicator displayed'); 14652 } else { 14653 console.error('[CONFLICT DETECT] â ï¸ Conflict indicator element not found in DOM!'); 14654 } 14655 } 14656 14657 // Hide conflict indicator icon 14658 function hideConflictIndicator() { 14659 const indicator = document.getElementById('conflictIndicator'); 14660 if (indicator) { 14661 indicator.style.display = 'none'; 14662 indicator.style.visibility = 'hidden'; 14663 indicator.style.opacity = '0'; 14664 } 14665 } 14666 14667 // Start/stop polling based on edit mode 14668 function updatePolling() { 14669 const isEditMode = document.body.classList.contains('edit-mode-active'); 14670 14671 // Always hide indicator when not in edit mode 14672 if (!isEditMode) { 14673 hideConflictIndicator(); 14674 } 14675 14676 if (isEditMode && !conflictPollInterval) { 14677 // Start polling every 10 seconds 14678 conflictPollInterval = setInterval(checkForConflicts, 10000); 14679 console.error('[CONFLICT DETECT] â Started conflict detection polling (every 10s)'); 14680 14681 // Also check immediately 14682 checkForConflicts(); 14683 } else if (!isEditMode && conflictPollInterval) { 14684 // Stop polling when not in edit mode 14685 clearInterval(conflictPollInterval); 14686 conflictPollInterval = null; 14687 hideConflictIndicator(); 14688 console.error('[CONFLICT DETECT] Stopped conflict detection polling'); 14689 } 14690 } 14691 14692 // Initialize on page load 14693 if (document.readyState === 'loading') { 14694 document.addEventListener('DOMContentLoaded', function() { 14695 initializeConfigTimestamp(); 14696 // Watch for edit mode changes 14697 const editModeObserver = new MutationObserver(updatePolling); 14698 editModeObserver.observe(document.body, { 14699 attributes: true, 14700 attributeFilter: ['class'] 14701 }); 14702 updatePolling(); 14703 }); 14704 } else { 14705 initializeConfigTimestamp(); 14706 // Watch for edit mode changes 14707 const editModeObserver = new MutationObserver(updatePolling); 14708 editModeObserver.observe(document.body, { 14709 attributes: true, 14710 attributeFilter: ['class'] 14711 }); 14712 updatePolling(); 14713 } 14714})(); 14715 14716</script>
14716 14717</head> 14718<body class="menu-layout-sidebar"> 14719 <a class="hydra-skip-link" href="#hydra-main-content">Skip to content</a> 14720<nav class="hydra-seo-nav" aria-label="Site navigation"> 14721 <ul> 14722 <li><a href="/about-the-book">About the book</a></li> 14723 <li><a href="/podcast">Podcast</a></li> 14724 <li><a href="/always-take-notes-book">Always Take Notes book</a></li> 14725 <li><a href="/longform-journalism">Longform journalism</a></li> 14726 <li><a href="/about">About</a></li> 14727 <li><a href="/contact">Contact</a></li> 14728 </ul> 14729</nav> 14730 14731 14732 <div class="global-header-controls"> 14733 14734 <button id="editButton" class="btn-icon btn-primary" onclick="toggleEditMode()" style="display: none;" data-help-id="edit-toggle"> 14735 <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> 14736 <g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"> 14737 <path d="m16.214 4.982l1.402-1.401a1.982 1.982 0 0 1 2.803 2.803l-1.401 1.402m-2.804-2.804l-5.234 5.234c-1.045 1.046-1.568 1.568-1.924 2.205S8.342 14.561 8 16c1.438-.342 2.942-.7 3.579-1.056s1.16-.879 2.205-1.924l5.234-5.234m-2.804-2.804l2.804 2.804"/> 14738 <path d="M21 12c0 4.243 0 6.364-1.318 7.682S16.242 21 12 21s-6.364 0-7.682-1.318S3 16.242 3 12s0-6.364 1.318-7.682S7.758 3 12 3"/> 14739 </g> 14740 </svg> 14741 </button> 14742 <button id="conflictIndicator" class="btn-icon btn-primary" onclick="window.location.reload()" style="display: none;" data-custom-tooltip="Changes detected in another window" data-tooltip-position="right"> 14743 <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="color: white;"> 14744 <path fill="currentColor" d="M2 12c0 5 4 9 9 9c2.4 0 4.7-.9 6.4-2.6l-1.5-1.5c-1.3 1.4-3 2.1-4.9 2.1c-6.2 0-9.4-7.5-4.9-11.9S18 5.8 18 12h-3l4 4h.1l3.9-4h-3c0-5-4-9-9-9s-9 4-9 9m8 3h2v2h-2zm0-8h2v6h-2z"/> 14745 </svg> 14746 </button> 14747 </div> 14748 14749 <div class="container"> 14750 <div class="mobile-header"> 14751 <div class="mobile-header-content"> 14752 <div class="horizontal-header-logo"></div> 14753 <div class="horizontal-menu-container"></div> 14754 </div> 14755 <button id="hamburgerBtn" class="hamburger-btn" type="button" aria-label="Menu" aria-expanded="false" aria-controls="siteSidebar"> 14756 <span class="hamburger-icon" aria-hidden="true"> 14757 <span class="hamburger-line"></span> 14758 <span class="hamburger-line"></span> 14759 <span class="hamburger-line"></span> 14760 </span> 14761 </button> 14762 </div> 14763 <div id="mobileBackdrop" class="mobile-backdrop"></div> 14764 14765 <div class="sidebar" id="siteSidebar" role="navigation" aria-label="Site"> 14766 <div class="sidebar-header"> 14767</div> 14768 14769 14770 <div class="edit-controls" data-help-id="edit-toolbar"> 14771 <button type="button" class="btn-icon btn-primary" onclick="openHydraHelp()" data-custom-tooltip="Help" data-help-id="help-toolbar"> 14772 <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"> 14773 <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.195 8.765A4 4 0 1 1 12 14v1m.05 4v.1h-.1V19z" /> 14774 </svg> 14775 </button> 14776 <button class="btn-icon btn-primary" onclick="toggleStyleEditor(undefined, event)" data-custom-tooltip="Edit Style" data-help-id="style-toolbar"> 14777 <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> 14778 <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m19 12.13l-6.061 6.077c-1.783 1.788-2.675 2.682-3.77 2.78q-.27.025-.543 0c-1.094-.098-1.986-.992-3.769-2.78l-2.02-2.026a2.87 2.87 0 0 1 0-4.052m16.163 0l-8.082-8.103M19 12.129H2.837m8.081-8.103l-8.081 8.103m8.081-8.103L8.898 2M22 20a2 2 0 1 1-4 0c0-1.105 2-3 2-3s2 1.895 2 3" color="currentColor"/> 14779 </svg> 14780 </button> 14781 <button class="btn-icon btn-primary" onclick="toggleAddForm()" data-custom-tooltip="Add"> 14782 <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> 14783 <g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"> 14784 <path d="M13 2h.273c3.26 0 4.892 0 6.024.798c.324.228.612.5.855.805c.848 1.066.848 2.6.848 5.67v2.545c0 2.963 0 4.445-.469 5.628c-.754 1.903-2.348 3.403-4.37 4.113c-1.257.441-2.83.441-5.98.441c-1.798 0-2.698 0-3.416-.252c-1.155-.406-2.066-1.263-2.497-2.35C4 18.722 4 17.875 4 16.182V12"/> 14785 <path d="M21 12a3.333 3.333 0 0 1-3.333 3.333c-.666 0-1.451-.116-2.098.057a1.67 1.67 0 0 0-1.179 1.179c-.173.647-.057 1.432-.057 2.098A3.333 3.333 0 0 1 11 22m0-16H3m4-4v8"/> 14786 </g> 14787 </svg> 14788 </button> 14789 <button class="btn-icon btn-primary" onclick="toggleSidebarElementForm(
14789)" data-custom-tooltip="Add Element"> 14790 <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> 14791 <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M18 2v8m4-4h-8M2 6c0-1.4 0-2.1.272-2.635a2.5 2.5 0 0 1 1.093-1.093C3.9 2 4.6 2 6 2s2.1 0 2.635.272a2.5 2.5 0 0 1 1.093 1.093C10 3.9 10 4.6 10 6s0 2.1-.272 2.635a2.5 2.5 0 0 1-1.093 1.093C8.1 10 7.4 10 6 10s-2.1 0-2.635-.272a2.5 2.5 0 0 1-1.093-1.093C2 8.1 2 7.4 2 6m0 12c0-1.4 0-2.1.272-2.635a2.5 2.5 0 0 1 1.093-1.092C3.9 14 4.6 14 6 14s2.1 0 2.635.273a2.5 2.5 0 0 1 1.093 1.092C10 15.9 10 16.6 10 18s0 2.1-.272 2.635a2.5 2.5 0 0 1-1.093 1.092C8.1 22 7.4 22 6 22s-2.1 0-2.635-.273a2.5 2.5 0 0 1-1.093-1.092C2 20.1 2 19.4 2 18m12 0c0-1.4 0-2.1.273-2.635a2.5 2.5 0 0 1 1.092-1.092C15.9 14 16.6 14 18 14s2.1 0 2.635.273a2.5 2.5 0 0 1 1.092 1.092C22 15.9 22 16.6 22 18s0 2.1-.273 2.635a2.5 2.5 0 0 1-1.092 1.092C20.1 22 19.4 22 18 22s-2.1 0-2.635-.273a2.5 2.5 0 0 1-1.092-1.092C14 20.1 14 19.4 14 18" color="currentColor"/> 14792 </svg> 14793 </button> 14794<button class="btn-icon btn-primary" onclick="toggleMetadataEditor()" data-custom-tooltip="Site Metadata"> 14795<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M7.5 4.945H16a1.5 1.5 0 0 1 1.5 1.5v1.5m-2.5 5H9m3 4H9"/><path d="M18.497 2H6.307c-.496 0-1.005.073-1.406.368c-1.274.935-2.256 3.02-.273 4.903c.556.528 1.334.72 2.099.72h11.557c.793 0 2.216.113 2.216 2.536v7.454c0 2.22-1.79 4.019-3.997 4.019h-9.03c-2.204 0-3.807-1.557-3.933-3.929L3.506 5.166"/></g></svg> 14796</button> 14797<button id="logoutButton" class="btn-icon btn-primary" onclick="logout()" data-custom-tooltip="Log Out" style="display: none;"> 14798 <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> 14799 <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m11 3l-.663.234c-2.578.91-3.868 1.365-4.602 2.403S5 8.043 5 10.777v2.445c0 2.735 0 4.102.735 5.14c.734 1.039 2.024 1.494 4.602 2.404L11 21m10-9H11m10 0c0-.7-1.994-2.008-2.5-2.5M21 12c0 .7-1.994 2.008-2.5 2.5" color="currentColor"/> 14800 </svg> 14801 </button> 14802
14802<script> 14803 14804// Initialize mobile menu functionality 14805function initMobileMenu() { 14806 const hamburgerBtn = document.getElementById('hamburgerBtn'); 14807 const sidebar = document.querySelector('.sidebar'); 14808 const mobileBackdrop = document.getElementById('mobileBackdrop'); 14809 14810 // --- ADDED CHECK: Only update header if layout is NOT horizontal --- 14811 let currentLayout = 'sidebar'; // Default 14812 if (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings) { 14813 currentLayout = window.MenuStyleCustomizer.settings.menuLayout || 'sidebar'; 14814 } 14815 14816 // --- END ADDED CHECK --- 14817 14818 // Ensure backdrop is properly hidden for horizontal layout on page load 14819 if (mobileBackdrop && document.body.classList.contains('menu-layout-horizontal')) { 14820 mobileBackdrop.classList.remove('mobile-open'); 14821 mobileBackdrop.classList.remove('active'); 14822 // Clear any inline overrides - CSS controls backdrop visibility via .mobile-open 14823 mobileBackdrop.style.display = ''; 14824 mobileBackdrop.style.pointerEvents = ''; 14825 mobileBackdrop.style.opacity = ''; 14826 mobileBackdrop.style.visibility = ''; 14827 } 14828 14829 // Toggle mobile menu when hamburger is clicked 14830 hamburgerBtn.addEventListener('click', function(e) { 14831 e.stopPropagation(); 14832 toggleMobileMenu(); // Assumes this function exists 14833 }); 14834 14835 // Close mobile menu when backdrop is clicked 14836 if (mobileBackdrop) { 14837 mobileBackdrop.addEventListener('click', function() { 14838 closeMobileMenu(); // Assumes this function exists 14839 }); 14840 } 14841 14842 // Escape closes mobile menu (keyboard / SR) 14843 document.addEventListener('keydown', function(e) { 14844 if (e.key !== 'Escape') return; 14845 const sb = document.querySelector('.sidebar'); 14846 if (sb && sb.classList.contains('mobile-open') && typeof closeMobileMenu === 'function') { 14847 closeMobileMenu(); 14848 } 14849 }); 14850 14851 // Handle window resize events to ensure proper state 14852 window.addEventListener('resize', function() { 14853 // If we're not on mobile anymore, remove mobile-open class 14854 if (window.innerWidth > 768 && sidebar.classList.contains('mobile-open')) { 14855 closeMobileMenu(); // Assumes this function exists 14856 } 14857 }); 14858} 14859 14860document.addEventListener('keydown', function(e) { 14861 // Check for caret (Shift+6) 14862 if (e.key === '^' || (e.key === '6' && e.shiftKey)) { 14863 // Only work in edit mode 14864 if (window.isInEditMode && window.isInEditMode()) { 14865 console.log('Import shortcut detected'); 14866 toggleImportClassicForm(); 14867 e.preventDefault(); 14868 } 14869 } 14870}); 14871 14872// Enhanced tooltip management with automatic timeouts 14873 14874// Store active tooltip timeouts by ID 14875window.tooltipTimeouts = {}; 14876 14877function showTooltip(trigger) { 14878 const tooltipText = trigger.getAttribute('data-custom-tooltip'); 14879 if (!tooltipText) return; 14880 14881 // Get or create the main tooltip container 14882 let container = document.getElementById('tooltip-container'); 14883 if (!container) { 14884 console.log("Tooltip container not found, creating one."); 14885 container = document.createElement('div'); // Assign to 'container' 14886 container.id = 'tooltip-container'; 14887 // CSS for #tooltip-container should make it position:fixed, top:0, left:0, pointer-events:none, etc. 14888 document.body.appendChild(container); 14889 } 14890 14891 const tooltipId = `tooltip-${trigger.id || Math.random().toString(36).substr(2, 9)}`; 14892 14893 // Remove any existing tooltip for this trigger 14894 const existingTooltip = document.getElementById(tooltipId); 14895 if (existingTooltip) { 14896 existingTooltip.remove(); 14897 } 14898 14899 // Clear any existing timeout for this tooltip 14900 if (!window.tooltipTimeouts) { 14901 window.tooltipTimeouts = {}; // Initialize if it doesn't exist 14902 } 14903 14904 if (window.tooltipTimeouts[tooltipId]) { 14905 clearTimeout(window.tooltipTimeouts[tooltipId]); 14906 delete window.tooltipTimeouts[tooltipId]; 14907 } 14908 14909 // Create tooltip element 14910 const tooltip = document.createElement('div'); 14911 tooltip.className = 'tooltip'; // Ensure your CSS styles .tooltip 14912 tooltip.id = tooltipId; 14913 tooltip.textContent = tooltipText; 14914 14915 // Add to container (now 'container' is guaranteed to be an element) 14916 container.appendChild(tooltip); 14917 14918 // Position tooltip 14919 if (typeof positionTooltip === 'function') { 14920 positionTooltip(tooltip, trigger); 14921 } else { 14922 console.error("positionTooltip function is not defined."); 14923 tooltip.remove(); // Clean up if we can't position 14924 return; 14925 } 14926 14927 // Show with animation (slight delay for CSS transition) 14928 setTimeout(() => { 14929 tooltip.style.opacity = '1'; 14930 // The transform for animation should be handled by CSS transitions 14931 // e.g., .tooltip.visible { transform: translateX(-50%) translateY(-5px); } 14932 // Or ensure positionTooltip sets final transform if it needs to override CSS. 14933 // For now, assuming CSS handles the animated transform from its initial state. 14934 }, 10); 14935 14936 trigger.setAttribute('data-active-tooltip', tooltipId); 14937 14938 // Set automatic timeout to hide after 3 seconds 14939 window.tooltipTimeouts[tooltipId] = setTimeout(() => { 14940 hideTooltip(trigger); // Ensure hideTooltip function exists 14941 }, 3000); 14942 14943 // Also set up a global timer to ensure all tooltips are dismissed 14944 if (window.globalTooltipTimer) { 14945 clearTimeout(window.globalTooltipTimer); 14946 } 14947 window.globalTooltipTimer = setTimeout(() => { 14948 // Force hide all visible tooltips after 3 seconds 14949 const allTooltips = document.querySelectorAll('.tooltip'); 14950 allTooltips.forEach(tooltip => { 14951 if (tooltip.style.opacity !== '0') { 14952 tooltip.style.opacity = '0'; 14953 setTimeout(() => { 14954 if (tooltip.parentNode) { 14955 tooltip.parentNode.removeChild(tooltip); 14956 } 14957 }, 200); 14958 } 14959 }); 14960 // Clear all timeout references 14961 if (window.tooltipTimeouts) { 14962 for (const id in window.tooltipTimeouts) { 14963 clearTimeout(window.tooltipTimeouts[id]); 14964 } 14965 window.tooltipTimeouts = {}; 14966 } 14967 }, 3000); 14968} 14969 14970// Function to hide a tooltip 14971function hideTooltip(trigger) { 14972 const tooltipId = trigger.getAttribute('data-active-tooltip'); 14973 if (!tooltipId) return; 14974 14975 const tooltip = document.getElementById(tooltipId); 14976 if (tooltip) { 14977 // Fade out 14978 tooltip.style.opacity = '0'; 14979 tooltip.style.transform = 'translateX(-50%) translateY(0)'; 14980 14981 // Remove after animation 14982 setTimeout(() => { 14983 if (tooltip.parentNode) { 14984 tooltip.parentNode.removeChild(tooltip); 14985 } 14986 }, 200); 14987 } 14988 14989 // Clear any active timeout for this tooltip 14990 if (window.tooltipTimeouts && window.tooltipTimeouts[tooltipId]) { 14991 clearTimeout(window.tooltipTimeouts[tooltipId]); 14992 delete window.tooltipTimeouts[tooltipId]; 14993 } 14994 14995 // Clear reference 14996 trigger.removeAttribute('data-active-tooltip'); 14997 14998 // If this was the last tooltip, clear the global timer 14999 const remainingTooltips = document.querySelectorAll('.tooltip'); 15000 if (remainingTooltips.length === 0 && window.globalTooltipTimer) { 15001 clearTimeout(window.globalTooltipTimer); 15002 window.globalTooltipTimer = null; 15003 } 15004} 15005 15006// Function to initialize tooltips across the document 15007function initTooltipSystem() { 15008 // Check if container already exists 15009 let tooltipContainer = document.getElementById('tooltip-container'); 15010 15011 // Create it if it doesn't exist 15012 if (!tooltipContainer) { 15013 tooltipContainer = document.createElement('div'); 15014 tooltipContainer.id = 'tooltip-container'; 15015 document.body.appendChild(tooltipContainer); 15016 } 15017 15018 // Find all elements with data-custom-tooltip attribute 15019 const tooltipTriggers = document.querySelectorAll('[data-custom-tooltip]'); 15020 15021 // Add event listeners to each trigger
15022 tooltipTriggers.forEach(trigger => { 15023 // Skip if already initialized 15024 if (trigger.hasAttribute('data-custom-tooltip-initialized')) return; 15025 15026 // Mark as initialized 15027 trigger.setAttribute('data-custom-tooltip-initialized', 'true'); 15028 15029 // MouseEnter: Show tooltip 15030 trigger.addEventListener('mouseenter', () => { 15031 showTooltip(trigger); 15032 }); 15033 15034 // MouseLeave: Hide tooltip 15035 trigger.addEventListener('mouseleave', () => { 15036 hideTooltip(trigger); 15037 }); 15038 15039 // Click: Hide tooltip immediately 15040 trigger.addEventListener('click', () => { 15041 hideTooltip(trigger); 15042 }); 15043 15044 // Also handle focus/blur for accessibility 15045 trigger.addEventListener('focus', () => { 15046 showTooltip(trigger); 15047 }); 15048 15049 trigger.addEventListener('blur', () => { 15050 hideTooltip(trigger); 15051 }); 15052 }); 15053 15054 // Set up a failsafe function to clear any orphaned tooltips 15055 window.clearAllTooltips = function() { 15056 const container = document.getElementById('tooltip-container'); 15057 if (container) { 15058 container.innerHTML = ''; 15059 } 15060 15061 // Clear all timeouts 15062 if (window.tooltipTimeouts) { 15063 for (const id in window.tooltipTimeouts) { 15064 clearTimeout(window.tooltipTimeouts[id]); 15065 } 15066 window.tooltipTimeouts = {}; 15067 } 15068 15069 // Clear global timer 15070 if (window.globalTooltipTimer) { 15071 clearTimeout(window.globalTooltipTimer); 15072 window.globalTooltipTimer = null; 15073 } 15074 15075 // Clear all data-active-tooltip attributes 15076 document.querySelectorAll('[data-active-tooltip]').forEach(el => { 15077 el.removeAttribute('data-active-tooltip'); 15078 }); 15079 }; 15080 15081 // Initialize new tooltips when DOM changes 15082 setupMutationObserver(); 15083 15084 // Add a global function to manually dismiss all tooltips (for testing) 15085 window.dismissAllTooltips = function() { 15086 const allTooltips = document.querySelectorAll('.tooltip'); 15087 allTooltips.forEach(tooltip => { 15088 if (tooltip.style.opacity !== '0') { 15089 tooltip.style.opacity = '0'; 15090 setTimeout(() => { 15091 if (tooltip.parentNode) { 15092 tooltip.parentNode.removeChild(tooltip); 15093 } 15094 }, 200); 15095 } 15096 }); 15097 15098 // Clear all timeout references 15099 if (window.tooltipTimeouts) { 15100 for (const id in window.tooltipTimeouts) { 15101 clearTimeout(window.tooltipTimeouts[id]); 15102 } 15103 window.tooltipTimeouts = {}; 15104 } 15105 15106 // Clear global timer 15107 if (window.globalTooltipTimer) { 15108 clearTimeout(window.globalTooltipTimer); 15109 window.globalTooltipTimer = null; 15110 } 15111 15112 // Clear all data-active-tooltip attributes 15113 document.querySelectorAll('[data-active-tooltip]').forEach(el => { 15114 el.removeAttribute('data-active-tooltip'); 15115 }); 15116 }; 15117} 15118 15119function positionTooltip(tooltip, trigger) { 15120 const triggerRect = trigger.getBoundingClientRect(); // Viewport-relative coordinates 15121 15122 // To accurately get tooltip dimensions, temporarily make it visible but off-screen. 15123 // The #tooltip-container is position:fixed, so the tooltip's absolute positioning 15124 // will be relative to the viewport. 15125 tooltip.style.visibility = 'hidden'; 15126 tooltip.style.display = 'block'; // Or 'inline-block' if that's its intended final display 15127 // No need to change position to 'absolute' here if its CSS is already 'position: absolute;' 15128 // within the 'fixed' #tooltip-container. 15129 15130 const tooltipHeight = tooltip.offsetHeight; 15131 const tooltipWidth = tooltip.offsetWidth; 15132 15133 // Restore visibility; opacity transition will handle the fade-in. 15134 tooltip.style.visibility = ''; 15135 // tooltip.style.display = ''; // Let CSS manage display if opacity is used for showing/hiding 15136 15137 // Check if tooltip should be positioned to the right (for conflict indicator) 15138 const tooltipPosition = trigger.getAttribute('data-tooltip-position'); 15139 if (tooltipPosition === 'right') { 15140 // Position to the right of the trigger 15141 const viewportWidth = window.innerWidth; 15142 const viewportHeight = window.innerHeight; 15143 let left = triggerRect.right + 10; // 10px gap from the right edge of trigger 15144 let top = triggerRect.top + (triggerRect.height / 2) - (tooltipHeight / 2); // Vertically centered 15145 15146 // Recalculate tooltip dimensions after allowing text wrap 15147 tooltip.style.maxWidth = '200px'; 15148 tooltip.style.whiteSpace = 'normal'; 15149 tooltip.style.wordWrap = 'break-word'; 15150 const tooltipWidthAfterWrap = tooltip.offsetWidth; 15151 const tooltipHeightAfterWrap = tooltip.offsetHeight; 15152 15153 // If tooltip goes off-screen to the right, try positioning to the left of the button 15154 if (left + tooltipWidthAfterWrap > viewportWidth - 5) { 15155 left = triggerRect.left - tooltipWidthAfterWrap - 10; // Position to the left of button 15156 // If it still goes off the left edge, position it at the left edge with padding 15157 if (left < 5) { 15158 left = 5; // Keep it at least 5px from the left edge 15159 // The tooltip will wrap to fit within the available space 15160 tooltip.style.maxWidth = `${viewportWidth - left - 10}px`; 15161 } 15162 } else {
15163 // Tooltip fits on the right, but make sure it doesn't go off-screen 15164 if (left + tooltipWidthAfterWrap > viewportWidth - 5) { 15165 tooltip.style.maxWidth = `${viewportWidth - left - 10}px`; 15166 } 15167 } 15168 15169 // Recalculate after max-width adjustment 15170 const finalTooltipWidth = tooltip.offsetWidth; 15171 const finalTooltipHeight = tooltip.offsetHeight; 15172 15173 // Ensure tooltip doesn't go off-screen on the right side 15174 if (left + finalTooltipWidth > viewportWidth - 5) { 15175 left = viewportWidth - finalTooltipWidth - 5; 15176 } 15177 15178 // Adjust if tooltip goes off-screen vertically 15179 if (top < 5) { 15180 top = 5; 15181 } else if (top + finalTooltipHeight > viewportHeight - 5) { 15182 top = viewportHeight - finalTooltipHeight - 5; 15183 } 15184 15185 tooltip.style.left = `${left}px`; 15186 tooltip.style.top = `${top}px`; 15187 tooltip.classList.remove('tooltip-bottom'); // No arrow needed for side positioning 15188 return; 15189 } 15190 15191 // Calculate desired horizontal center point of the trigger 15192 const triggerCenter = triggerRect.left + triggerRect.width / 2; 15193 15194 // Calculate tooltip's left position to center it under/over trigger's center 15195 let left = triggerCenter - tooltipWidth / 2; 15196 15197 // Default position: above the trigger 15198 // Arrow height (8px) + desired gap (e.g., 2px) = 10px total offset 15199 let top = triggerRect.top - tooltipHeight - 10; 15200 tooltip.classList.remove('tooltip-bottom'); // Manages arrow direction via CSS 15201 15202 // Check if it goes off-screen at the top (add a small buffer, e.g., 5px) 15203 if (top < 5) { 15204 // Position below the trigger 15205 top = triggerRect.bottom + 10; // 10px gap (arrow height + small space) 15206 tooltip.classList.add('tooltip-bottom'); 15207 } 15208 15209 // Boundary checks for left position to prevent going off-screen horizontally 15210 const viewportWidth = window.innerWidth; 15211 if (left < 5) { // 5px buffer from left edge 15212 left = 5; 15213 } else if (left + tooltipWidth > viewportWidth - 5) { // 5px buffer from right edge 15214 left = viewportWidth - tooltipWidth - 5; 15215 } 15216 15217 tooltip.style.left = `${left}px`; 15218 tooltip.style.top = `${top}px`; 15219 15220 // The CSS for .tooltip might have `transform: translateX(-50%)` for initial centering. 15221 // Since JS now calculates the exact 'left' for centering, 15222 // we can clear any such transform that might interfere. 15223 // However, if your CSS animation relies on a transform, adjust accordingly. 15224 // If your CSS only uses opacity for transition, this is fine: 15225 tooltip.style.transform = ''; 15226} 15227 15228// Watch for new tooltip triggers added to the DOM 15229function setupMutationObserver() { 15230 // Check if MutationObserver is available 15231 if (!window.MutationObserver) return; 15232 15233 // Create observer instance 15234 const observer = new MutationObserver((mutations) => { 15235 let shouldInit = false; 15236 15237 // Check if relevant nodes were added 15238 mutations.forEach(mutation => { 15239 if (mutation.type === 'childList') { 15240 mutation.addedNodes.forEach(node => { 15241 // Check if it's an Element and has data-custom-tooltip or contains elements with data-custom-tooltip 15242 if (node.nodeType === 1) { // ELEMENT_NODE 15243 if (node.hasAttribute && node.hasAttribute('data-custom-tooltip')) { 15244 shouldInit = true; 15245 } else if (node.querySelectorAll) { 15246 const tooltips = node.querySelectorAll('[data-custom-tooltip]'); 15247 if (tooltips.length) shouldInit = true; 15248 } 15249 } 15250 }); 15251 } 15252 }); 15253 15254 // If relevant nodes found, reinitialize tooltips 15255 if (shouldInit) { 15256 initTooltipSystem(); 15257 } 15258 }); 15259 15260 // Start observing the entire document with the configured parameters 15261 observer.observe(document.body, { 15262 childList: true, 15263 subtree: true 15264 }); 15265} 15266 15267// Initialize when DOM is ready 15268document.addEventListener('DOMContentLoaded', initTooltipSystem); 15269 15270// Also initialize immediately if document already loaded 15271if (document.readyState === 'complete' || document.readyState === 'interactive') { 15272 initTooltipSystem(); 15273} 15274 15275// Add a global safety function that runs every minute to clear any tooltips that might have gotten stuck 15276setInterval(() => { 15277 // Get all visible tooltips 15278 const visibleTooltips = document.querySelectorAll('.tooltip[style*="opacity: 1"]'); 15279 15280 // If any are found, check how long they've been visible 15281 if (visibleTooltips.length > 0) { 15282 console.log('Found visible tooltips during cleanup check, clearing them'); 15283 window.clearAllTooltips(); 15284 } 15285}, 5000); // 15286 15287// Mobile drawer a11y: Tab cycle + restore focus. No visual/mouse behavior changes. 15288var _mobileMenuFocusTrapHandler = null; 15289 15290function getMobileMenuFocusables(sidebar) { 15291 if (!sidebar) return []; 15292 return Array.prototype.filter.call( 15293 sidebar.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not
15293([tabindex="-1"])'), 15294 function (el) { 15295 if (el.getAttribute('aria-hidden') === 'true') return false; 15296 var style = typeof getComputedStyle === 'function' ? getComputedStyle(el) : null; 15297 if (style && (style.display === 'none' || style.visibility === 'hidden')) return false; 15298 return el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement; 15299 } 15300 ); 15301} 15302 15303function releaseMobileMenuFocusTrap() { 15304 if (_mobileMenuFocusTrapHandler) { 15305 document.removeEventListener('keydown', _mobileMenuFocusTrapHandler, true); 15306 _mobileMenuFocusTrapHandler = null; 15307 } 15308} 15309 15310function installMobileMenuFocusTrap(sidebar) { 15311 releaseMobileMenuFocusTrap(); 15312 if (!sidebar) return; 15313 _mobileMenuFocusTrapHandler = function (event) { 15314 if (event.key !== 'Tab') return; 15315 var sb = document.querySelector('.sidebar'); 15316 if (!sb || !sb.classList.contains('mobile-open')) return; 15317 var focusables = getMobileMenuFocusables(sb); 15318 var hamburger = document.getElementById('hamburgerBtn'); 15319 // Include hamburger so Shift+Tab from first item returns to the control that opened the drawer 15320 var cycle = hamburger ? [hamburger].concat(focusables.filter(function (el) { return el !== hamburger; })) : focusables; 15321 if (!cycle.length) return; 15322 var first = cycle[0]; 15323 var last = cycle[cycle.length - 1]; 15324 if (event.shiftKey) { 15325 if (document.activeElement === first || (sb.contains(document.activeElement) === false && document.activeElement !== hamburger)) { 15326 event.preventDefault(); 15327 last.focus(); 15328 } 15329 } else if (document.activeElement === last) { 15330 event.preventDefault(); 15331 first.focus(); 15332 } 15333 }; 15334 document.addEventListener('keydown', _mobileMenuFocusTrapHandler, true); 15335} 15336 15337// Toggle mobile menu state 15338function toggleMobileMenu() { 15339const sidebar = document.querySelector('.sidebar'); 15340const hamburgerBtn = document.getElementById('hamburgerBtn'); 15341const mobileBackdrop = document.getElementById('mobileBackdrop'); 15342 15343// Check if we're in horizontal layout mode 15344const isHorizontalLayout = document.body.classList.contains('menu-layout-horizontal'); 15345 15346sidebar.classList.toggle('mobile-open'); 15347hamburgerBtn.classList.toggle('active'); 15348const isOpen = sidebar.classList.contains('mobile-open'); 15349hamburgerBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); 15350 15351if (mobileBackdrop) { 15352 if (isHorizontalLayout) { 15353 // For horizontal layout on mobile, use normal backdrop (tap outside to close) 15354 // Clear any inline overrides so CSS can control visibility 15355 mobileBackdrop.style.display = ''; 15356 mobileBackdrop.style.pointerEvents = ''; 15357 mobileBackdrop.style.opacity = ''; 15358 mobileBackdrop.style.visibility = ''; 15359 mobileBackdrop.classList.toggle('mobile-open', isOpen); 15360 setTimeout(() => { 15361 mobileBackdrop.classList.toggle('active', isOpen); 15362 }, 10); 15363 } else { 15364 // For other layouts, use normal backdrop behavior 15365 mobileBackdrop.classList.toggle('mobile-open'); 15366 // Slight delay to show opacity transition 15367 setTimeout(() => { 15368 mobileBackdrop.classList.toggle('active', isOpen); 15369 }, 10); 15370 } 15371} 15372 15373// Prevent body scrolling when menu is open 15374document.body.style.overflow = isOpen ? 'hidden' : ''; 15375 15376if (isOpen) { 15377 // Trap Tab only â do not steal focus on mouse/touch open (avoids focus rings / scroll jumps) 15378 installMobileMenuFocusTrap(sidebar); 15379} else { 15380 releaseMobileMenuFocusTrap(); 15381 // Restore focus only if keyboard focus was inside the drawer 15382 if (hamburgerBtn && sidebar && sidebar.contains(document.activeElement)) { 15383 try { hamburgerBtn.focus(); } catch (e) {} 15384 } 15385} 15386} 15387 15388// Close the mobile menu 15389function closeMobileMenu() { 15390const sidebar = document.querySelector('.sidebar'); 15391const hamburgerBtn = document.getElementById('hamburgerBtn'); 15392const mobileBackdrop = document.getElementById('mobileBackdrop'); 15393const wasOpen = sidebar && sidebar.classList.contains('mobile-open'); 15394 15395// Check if we're in horizontal layout mode 15396const isHorizontalLayout = document.body.classList.contains('menu-layout-horizontal'); 15397 15398sidebar.classList.remove('mobile-open'); 15399hamburgerBtn.classList.remove('active'); 15400if (hamburgerBtn) hamburgerBtn.setAttribute('aria-expanded', 'false'); 15401 15402if (mobileBackdrop) { 15403 if (isHorizontalLayout) { 15404 // For horizontal layout, clear inline overrides and remove mobile-open 15405 mobileBackdrop.style.display = ''; 15406 mobileBackdrop.style.pointerEvents = ''; 15407 mobileBackdrop.style.opacity = ''; 15408 mobileBackdrop.style.visibility = ''; 15409 mobileBackdrop.classList.remove('mobile-open'); 15410 mobileBackdrop.classList.remove('active'); 15411 } else { 15412 // For other layouts, use normal backdrop behavior 15413 mobileBackdrop.classList.remove('active'); 15414 setTimeout(() => { 15415 mobileBackdrop.classList.remove('mobile-open'); 15416 }, 300); // Match transition duration 15417 } 15418} 15419
15420// Re-enable body scrolling 15421document.body.style.overflow = ''; 15422releaseMobileMenuFocusTrap(); 15423// Return focus to hamburger only if focus was inside the drawer (keyboard/SR); mouse users unchanged 15424if (wasOpen && hamburgerBtn && sidebar && sidebar.contains(document.activeElement)) { 15425 try { hamburgerBtn.focus(); } catch (e) {} 15426} 15427} 15428 15429function updateMobileHeaderContent() { 15430 const currentLayout = typeof getEffectiveMenuLayout === 'function' 15431 ? getEffectiveMenuLayout() 15432 : (window.MenuStyleCustomizer && window.MenuStyleCustomizer.settings 15433 ? window.MenuStyleCustomizer.settings.menuLayout || 'sidebar' 15434 : (document.body.classList.contains('menu-layout-horizontal') ? 'horizontal' : 'sidebar')); 15435 15436 const isMobileView = window.innerWidth <= 768; // Assuming 768px is your mobile breakpoint 15437 15438 const mobileHeaderContent = document.querySelector('.mobile-header-content'); 15439 if (!mobileHeaderContent) { 15440 console.warn("updateMobileHeaderContent: .mobile-header-content element not found."); 15441 return; 15442 } 15443 15444 // On mobile, center the content unless Space Colony (left-aligned title) 15445 if (isMobileView) { 15446 const isSpaceColonyTheme = typeof isSpaceColonySiteTheme === 'function' && isSpaceColonySiteTheme(); 15447 mobileHeaderContent.style.justifyContent = isSpaceColonyTheme ? 'flex-start' : 'center'; 15448 // Also ensure logo-menu-container doesn't grow 15449 const logoMenuContainer = mobileHeaderContent.querySelector('.logo-menu-container'); 15450 if (logoMenuContainer) { 15451 logoMenuContainer.style.flexGrow = '0'; 15452 logoMenuContainer.style.justifyContent = isSpaceColonyTheme ? 'flex-start' : 'center'; 15453 } 15454 } 15455 15456 console.log(`updateMobileHeaderContent: Layout: ${currentLayout}, Mobile: ${isMobileView}`); 15457 15458 // Clear previous generic mobile content (like centered logo/title) 15459 const existingMobileImage = mobileHeaderContent.querySelector('.mobile-header-image'); 15460 const existingMobileText = mobileHeaderContent.querySelector('.mobile-header-text, .mobile-header-menu-title, .mobile-header-title'); 15461 if (existingMobileImage) { 15462 // The img is always appended directly (never wrapped in an <a>). Remove it straight. 15463 // Also handle any legacy <a>-wrapped images left from older deployments. 15464 const imageParent = existingMobileImage.parentElement; 15465 if (imageParent && imageParent.tagName === 'A' && imageParent.parentElement === mobileHeaderContent) { 15466 imageParent.remove(); 15467 } else { 15468 existingMobileImage.remove(); 15469 } 15470 } 15471 // Sweep any stray empty <a> tags left by older deployments that used the wrapper approach 15472 Array.from(mobileHeaderContent.children).forEach(function(child) { 15473 if (child.tagName === 'A' && !child.querySelector('.mobile-header-image') && !(child.textContent || '').trim()) { 15474 child.remove(); 15475 } 15476 }); 15477 if (existingMobileText) existingMobileText.remove(); 15478 15479 if (currentLayout === 'horizontal' && !isMobileView) { 15480 // Desktop Horizontal Layout: 15481 // The logo is handled by renderHorizontalMenu placing it in .horizontal-header-logo. 15482 // The menu items are in .horizontal-menu-container. 15483 // Remove stray mobile-only title/image nodes (duplicate of horizontal-header-logo). 15484 mobileHeaderContent.querySelectorAll('.mobile-header-text, .mobile-header-menu-title, .mobile-header-title, .mobile-header-image').forEach(function(el) { 15485 el.remove(); 15486 }); 15487 console.log("updateMobileHeaderContent: Desktop horizontal layout, re-rendering horizontal menu to ensure sidebar elements are available."); 15488 if (typeof renderHorizontalMenu === 'function') { 15489 // Wait for sidebar elements to be available before rendering 15490 const waitForSidebarElements = (attempts = 0) => { 15491 const maxAttempts = 10; 15492 if (window.SidebarManager && window.SidebarManager.elements && window.SidebarManager.elements.length > 0) { 15493 console.log("updateMobileHeaderContent: Sidebar elements available, rendering horizontal menu."); 15494 renderHorizontalMenu(); 15495 } else if (attempts < maxAttempts) { 15496 const attemptNum = attempts + 1; 15497 console.log("updateMobileHeaderContent: Waiting for sidebar elements (attempt " + attemptNum + "/" + maxAttempts + ")..."); 15498 setTimeout(() => waitForSidebarElements(attempts + 1), 100); 15499 } else { 15500 console.warn("updateMobileHeaderContent: Sidebar elements not available after waiting, rendering anyway."); 15501 renderHorizontalMenu(); 15502 } 15503 }; 15504 waitForSidebarElements(); 15505 } 15506 return; 15507 } 15508 15509 // For Horizontal layout on mobile, we need to clear the horizontal menu items 15510 // and show only the mobile header logo/title 15511 if (currentLayout === 'horizontal' && isMobileView) { 15512 console.log("updateMobileHeaderContent: Mobile horizontal layout - clearing horizontal menu items and showing mobile logo."); 15513 15514 // Clear horizontal menu items from mobile header 15515 const horizontalMenuContainer = mobileHeaderContent.querySelector('.horizontal-menu
15515-container'); 15516 if (horizontalMenuContainer) { 15517 console.log("updateMobileHeaderContent: Clearing horizontal menu container"); 15518 horizontalMenuContainer.innerHTML = ''; 15519 } else { 15520 console.log("updateMobileHeaderContent: No horizontal menu container found to clear"); 15521 } 15522 15523 // Clear horizontal header logo from mobile header 15524 const horizontalHeaderLogo = mobileHeaderContent.querySelector('.horizontal-header-logo'); 15525 if (horizontalHeaderLogo) { 15526 console.log("updateMobileHeaderContent: Clearing horizontal header logo"); 15527 horizontalHeaderLogo.innerHTML = ''; 15528 } else { 15529 console.log("updateMobileHeaderContent: No horizontal header logo found to clear"); 15530 } 15531 15532 // Also clear any existing horizontal menu items that might be directly in mobile-header-content 15533 const existingHorizontalMenuItems = mobileHeaderContent.querySelectorAll('.horizontal-menu-item'); 15534 if (existingHorizontalMenuItems.length > 0) { 15535 console.log('updateMobileHeaderContent: Removing ' + existingHorizontalMenuItems.length + ' existing horizontal menu items'); 15536 existingHorizontalMenuItems.forEach(item => item.remove()); 15537 } 15538 } 15539 15540 // For Sidebar, Top, OR Horizontal layout on mobile: 15541 // Populate .mobile-header-content with the first sidebar element (logo/title). 15542 // The CSS will hide .horizontal-menu-container and .horizontal-header-logo on mobile for horizontal layout. 15543 15544 console.log("updateMobileHeaderContent: Populating with first sidebar element."); 15545 15546 // For horizontal layout on mobile, use the header-specific function to get logo/title 15547 // For other layouts, use the general function 15548 let firstElement; 15549 if (currentLayout === 'horizontal' && isMobileView) { 15550 firstElement = getFirstSidebarElementForHeader(); // Gets logo/title specifically 15551 console.log("updateMobileHeaderContent: Using getFirstSidebarElementForHeader for mobile horizontal layout"); 15552 } else { 15553 firstElement = getFirstSidebarElement(); // Gets any first element 15554 console.log("updateMobileHeaderContent: Using getFirstSidebarElement for other layouts"); 15555 } 15556 15557 if (firstElement) { 15558 console.log('updateMobileHeaderContent: First element found:', JSON.stringify(firstElement)); 15559 15560 if (firstElement.type === 'image' && (firstElement.imageData || firstElement.imageUrl)) { 15561 console.log('updateMobileHeaderContent: Creating mobile header image'); 15562 const img = document.createElement('img'); 15563 img.src = firstElement.imageUrl || firstElement.imageData; 15564 img.alt = resolveBrandLogoAlt(firstElement); 15565 img.className = 'mobile-header-image sidebar-image'; // For specific styling if needed 15566 15567 let mobileDisplayNode = img; 15568 if (firstElement.colorizeWithSiteColors && 15569 window.SidebarManager && 15570 typeof window.SidebarManager._buildSidebarImageDisplayNode === 'function') { 15571 const buildData = Object.assign({}, firstElement); 15572 if (!buildData.imageWidth) { 15573 buildData.imageWidth = 'auto'; 15574 } 15575 mobileDisplayNode = window.SidebarManager._buildSidebarImageDisplayNode(img, buildData); 15576 } 15577 15578 // Opt-in per-image padding wrapper (no-op when unset) 15579 if (window.SidebarManager && 15580 typeof window.SidebarManager._wrapWithImagePadding === 'function') { 15581 mobileDisplayNode = window.SidebarManager._wrapWithImagePadding(mobileDisplayNode, firstElement); 15582 } 15583 15584 // Handle linking: always append the img directly so existing CSS centering is 15585 // not disrupted by an <a> wrapper (which the many !important positioning rules 15586 // were not written to handle). Navigation is wired via a click handler. 15587 if (firstElement.linkType && firstElement.linkType !== 'none') { 15588 let finalLinkHref = '#'; 15589 let isValidLink = false;
15590 const linkTarget = firstElement.linkTarget || '_self'; 15591 15592 if (firstElement.linkType === 'external' && firstElement.linkUrl) { 15593 finalLinkHref = firstElement.linkUrl; 15594 isValidLink = true; 15595 } else if (firstElement.linkType === 'internal' && firstElement.linkPageId) { 15596 const allGalleries = window.galleries || (typeof galleries !== 'undefined' ? galleries : []); 15597 const targetPage = allGalleries.find(g => g.id === parseInt(firstElement.linkPageId)); 15598 15599 if (targetPage) { 15600 const isPreviewMode = window.location.hostname === 'preview.neonsky.app'; 15601 if (isPreviewMode) { 15602 const pathParts = window.location.pathname.split('/').filter(Boolean); 15603 const siteGuid = pathParts[0]; 15604 if (siteGuid && targetPage.slug) { 15605 finalLinkHref = '/' + siteGuid + '/' + targetPage.slug; 15606 isValidLink = true; 15607 } else if (targetPage.url && targetPage.url.startsWith('/')) { 15608 finalLinkHref = targetPage.url; 15609 isValidLink = true; 15610 } 15611 } else { 15612 if (targetPage.slug) { 15613 finalLinkHref = '/' + targetPage.slug; 15614 isValidLink = true; 15615 } else if (targetPage.url && targetPage.url.startsWith('/')) { 15616 finalLinkHref = targetPage.url; 15617 isValidLink = true; 15618 } 15619 } 15620 } 15621 } 15622 15623 if (isValidLink) { 15624 mobileDisplayNode.style.cursor = 'pointer'; 15625 const capturedHref = finalLinkHref; 15626 const capturedTarget = linkTarget; 15627 const capturedLinkType = firstElement.linkType; 15628 const capturedPageId = firstElement.linkPageId; 15629 15630 mobileDisplayNode.addEventListener('click', (e) => { 15631 e.preventDefault(); 15632 if (capturedLinkType === 'internal' && capturedPageId) { 15633 const allGalleries = window.galleries || (typeof galleries !== 'undefined' ? galleries : []); 15634 const targetPage = allGalleries.find(g => g.id === parseInt(capturedPageId)); 15635 if (targetPage) { 15636 if (isPageMenuItem(targetPage) && typeof window.loadPage === 'function') { 15637 window.loadPage(targetPage.id); 15638 } else if (typeof window.loadGallery === 'function') { 15639 window.loadGallery(targetPage.id); 15640 } else { 15641 window.location.href = capturedHref; 15642 } 15643 } else { 15644 window.location.href = capturedHref; 15645 } 15646 } else { 15647 if (capturedTarget === '_blank') { 15648 window.open(capturedHref, '_blank'); 15649 } else { 15650 window.location.href = capturedHref; 15651 } 15652 } 15653 }); 15654 } 15655 } 15656 mobileHeaderContent.appendChild(mobileDisplayNode); 15657 } else if (firstElement.type === 'text' && firstElement.textContent) { 15658 console.log('updateMobileHeaderContent: Creating mobile header text'); 15659 const textDiv = document.createElement('div'); 15660 textDiv.className = 'mobile-header-text'; // For specific styling 15661 textDiv.innerHTML = firstElement.textContent; // Use innerHTML if textContent can contain HTML 15662 const titleEl = textDiv.querySelector('.site-title-text, h1, h2, p:last-of-type, p:only-of-type'); 15663 if (titleEl && !titleEl.classList.contains('site-title-text')) { 15664 titleEl.classList.add('site-title-text'); 15665 } 15666 mobileHeaderContent.appendChild(textDiv); 15667 } else if (firstElement.type === 'menu' && galleries && galleries.length > 0) { 15668 console.log('updateMobileHeaderContent: Creating mobile header menu title'); 15669 // Fallback for 'menu' type, find the first visible gallery title 15670 const firstVisibleGallery = galleries.find(g => g.visible !== false && !g.isSpacer); 15671 const titleText = firstVisibleGallery ? firstVisibleGallery.title : (firstElement.title || 'Menu'); 15672 const titleDiv = document.createElement('div'); 15673 titleDiv.className = 'mobile-header-menu-title'; // For specific styling 15674 titleDiv.textContent = titleText; 15675 mobileHeaderContent.appendChild(titleDiv); 15676 } else { // Default fallback 15677 console.log('updateMobileHeaderContent: Creating mobile header title (fallback)'); 15678 const titleDiv = document.createElement('div'); 15679 titleDiv.className = 'mobile-header-title'; // For specific styling 15680 titleDiv.textContent = firstElement.title || (galleries && galleries.length > 0 && galleries[0].title) || 'Menu'; 15681 mobileHeaderContent.appendChild(titleDiv); 15682 } 15683 } else { 15684 // Fallback if no sidebar elements exist 15685 const defaultTitle = document.createElement('div'); 15686 defaultTitle.className = 'mobile-header-title'; 15687 defaultTitle.textContent = 'Menu'; 15688 mobileHeaderContent.appendChild(defaultTitle); 15689 console.log("updateMobileHeaderContent: No first sidebar element found, showing default title."); 15690 } 15691 15692 if (typeof syncContentMarginToHorizontalHeader === 'function') { 15693 syncContentMarginToHorizontalHeader(); 15694 } 15695} 15696 15697// Helper function to get the first element in the sidebar 15698function getFirstSidebarElement() { 15699// Check if window.SidebarManager exists and has elements 15700if (window.SidebarManager && window.SidebarManager.elements && window.SidebarManager.elements.length > 0) { 15701// Filter to only visible elements 15702const visibleElements = window.SidebarManager.elements.filter(el => el.visible !== false && el.type !== 'spacer'); 15703 15704if (visibleElements.length > 0) { 15705 // Sort by position and return the first one 15706 return visibleElements.sort((a, b) => a.position - b.position)[0]; 15707} 15708} 15709 15710// If no sidebar elements, return first gallery as fallback 15711if (galleries.length > 0) { 15712const firstVisibleGallery = galleries.find(g => g.visible !== false); 15713if (firstVisibleGallery) { 15714 return { 15715 type: 'menu', 15716 title: firstVisibleGallery.title 15717 }; 15718} 15719} 15720 15721// Default fallback 15722return { 15723type: 'text', 15724title: 'Menu' 15725}; 15726} 15727// KEEP YOUR ORIGINAL initMobileMenu, toggleMobileMenu, closeMobileMenu FUNCTIONS EXACTLY AS THEY ARE 15728// Just replace the updateMobileTitle function and add our initialization code 15729 15730// Replace the existing updateMobileTitle function with this one 15731function updateMobileTitle() { 15732// Always call our implementation that shows the top sidebar element 15733updateMobileHeaderContent(); 15734} 15735 15736// Add window resize listener to handle mobile header content updates 15737window.addEventListener('resize', () => { 15738 // Debounce the resize event to avoid excessive calls 15739 clearTimeout(window.resizeTimeout); 15740 window.resizeTimeout = setTimeout(() => { 15741 if (typeof updateMobileHeaderContent === 'function') { 15742 updateMobileHeaderContent(); 15743 } 15744 if (typeof applyHorizontalLogoMenuLayoutStyles === 'function') { 15745 applyHorizontalLogoMenuLayoutStyles(); 15746 } 15747 if (typeof syncContentMarginToHorizontalHeader === 'function') { 15748 syncContentMarginToHorizontalHeader(); 15749 } 15750 }, 250); 15751}); 15752 15753//separate initialization function that doesn't replace the original initMobileMenu 15754function initTopElementDisplay() { 15755console.log("Initializing top element display"); 15756 15757// Run immediately 15758updateMobileHeaderContent(); 15759 15760if (typeof syncContentMarginToHorizontalHeader === 'function') { 15761 setTimeout(syncContentMarginToHorizontalHeader, 150); 15762} 15763 15764// Also hook into loadGallery to ensure it runs whenever galleries change 15765const originalLoadGallery = window.loadGallery; 15766if (typeof originalLoadGallery === 'function') { 15767window.loadGallery = function(id, event, options) { 15768 // Forward all args (including post-save { forceReload: true }) to the underlying loader 15769 const result = originalLoadGallery(id, event, options); 15770 updateMobileHeaderContent(); 15771 return result; 15772}; 15773} 15774} 15775 15776 15777// Initialize our functionality after a short delay to ensure SidebarManager is ready 15778document.addEventListener('DOMContentLoaded', function() { 15779// Short delay to ensure SidebarManager is initialized 15780setTimeout(initTopElementDisplay, 100); 15781 15782// Also run a bit later to catch any late-loading elements 15783setTimeout(updateMobileHeaderContent, 500); 15784}); 15785 15786// Run immediately if document is already loaded 15787if (document.readyState === 'complete' || document.readyState === 'interactive') { 15788setTimeout(initTopElementDisplay, 100); 15789} 15790 15791// Add a final fallback to ensure gallery visibility on window load 15792window.addEventListener('load', function() { 15793 setTimeout(ensureGalleryVisibility, 2000); 15794}); 15795 15796 15797(function() { 15798 /** 15799 * EnhancedGallerySystem - Comprehensive gallery script and state management 15800 */ 15801 class EnhancedGallerySystem { 15802 constructor() { 15803 // Core state tracking 15804 this.initialized = false;
15805 this.initPromise = null; 15806 this.isLoading = false; 15807 this.currentGalleryId = null; 15808 this.version = '260923-002'; // Increment when gallery scripts change (matches current versioned filenames) 15809 15810 // Track loaded resources 15811 this.loadedScripts = new Set(); 15812 this.loadedStyles = new Set(); 15813 15814 // Resources to ensure are loaded - Use Worker route (avoids CDN SSL issues) 15815 const galleryScriptsBase = window.location.origin + '/gallery-scripts/'; 15816 this.essentialResources = { 15817 scripts: [ 15818 { id: 'neon-gallery-main', src: galleryScriptsBase + 'neon-gallery-main-v260923-002.js' }, 15819 { id: 'neon-gallery-hp', src: galleryScriptsBase + 'neon-gallery-hp-hydra-v260923-002.js' } 15820 ], 15821 styles: [ 15822 { id: 'neon-gallery-css', href: galleryScriptsBase + 'neon-gallery-hp-hydra-v260923-002.css' } 15823 ] 15824 }; 15825 15826 // Keep track of original initialization functions 15827 this.originalInitFunctions = {}; 15828 15829 // Keep reference to gallery state between changes 15830 this.previousGalleryState = null; 15831 15832 // Track last initialization time per gallery ID to prevent duplicates 15833 this.lastInitializationTime = new Map(); 15834 this._loadInProgress = null; 15835 this._loadingGalleryId = null; 15836 } 15837 15838 /** 15839 * Setup the correct gallery container structure 15840 * @param {HTMLElement} container - The main container element 15841 * @returns {boolean} - Success indicator 15842 */ 15843setupCorrectGalleryContainer(container) { 15844 console.log('EnhancedGallerySystem: Setting up correct gallery container structure'); 15845 15846 // Validate container 15847 if (!container) { 15848 console.error('EnhancedGallerySystem: Main gallery container is null'); 15849 return false; 15850 } 15851 15852 try { 15853 // Clear the container 15854 container.innerHTML = ''; 15855 15856 // Create the exact container structure expected by the gallery script 15857 const neonContainer = document.createElement('div'); 15858 neonContainer.id = 'neon-gallery-container'; 15859 neonContainer.style.width = '100%'; 15860 neonContainer.style.height = '100%'; 15861 neonContainer.style.position = 'relative'; 15862 15863 // Add the container to the DOM 15864 container.appendChild(neonContainer); 15865 15866 // Set visibility 15867 container.style.opacity = '1'; 15868 container.style.display = 'block'; 15869 15870 console.log('EnhancedGallerySystem: Gallery container structure created'); 15871 return true; 15872 } catch (error) { 15873 console.error('EnhancedGallerySystem: Error setting up gallery container:', error); 15874 return false; 15875 } 15876} 15877 15878 /** 15879 * Ensure #neon-gallery-container exists before gallery scripts execute. 15880 * neon-gallery-main bails early without it; hp may leave neonGalleryUtils as {}. 15881 */ 15882 ensureGalleryContainerPlaceholder(container) { 15883 if (document.getElementById('neon-gallery-container')) { 15884 return document.getElementById('neon-gallery-container'); 15885 } 15886 const host = container || document.querySelector('.gallery-container') || document.getElementById('galleryFrame')?.parentElement; 15887 if (!host) { 15888 return null; 15889 } 15890 const neonContainer = document.createElement('div'); 15891 neonContainer.id = 'neon-gallery-container'; 15892 neonContainer.className = 'gallery-direct-content'; 15893 neonContainer.style.width = '100%'; 15894 neonContainer.style.height = '100%'; 15895 neonContainer.style.position = 'relative'; 15896 host.appendChild(neonContainer); 15897 console.log('EnhancedGallerySystem: Created placeholder neon-gallery-container before script load'); 15898 return neonContainer; 15899 } 15900 15901 /** 15902 * Wait for neon-gallery-main to expose loadGallerySettings; reload main if needed. 15903 */ 15904 async ensureNeonGalleryUtilsReady(container) { 15905 const hasLoadGallerySettings = () => 15906 typeof window.neonGalleryUtils?.loadGallerySettings === 'function'; 15907 15908 if (hasLoadGallerySettings()) { 15909 return true; 15910 } 15911 15912 const mainScript = this.essentialResources.scripts.find(s => s.id === 'neon-gallery-main'); 15913 if (!mainScript) { 15914 return false; 15915 } 15916 15917 console.warn('EnhancedGallerySystem: neonGalleryUtils.loadGallerySettings missing â reloading main script'); 15918 this.ensureGalleryContainerPlaceholder(container); 15919 15920 document.querySelectorAll('script[src*="neon-gallery-main"]').forEach(el => { 15921 if (el.parentNode) el.parentNode.removeChild(el); 15922 }); 15923 this.loadedScripts.delete('neon-gallery-main');
15924 try { delete window.__neonGalleryMainBootstrapWipeDone; } catch (e) { window.__neonGalleryMainBootstrapWipeDone = false; } 15925 15926 await new Promise((resolve, reject) => { 15927 const scriptElement = document.createElement('script'); 15928 scriptElement.id = 'neon-gallery-main'; 15929 scriptElement.src = `${mainScript.src}?v=${this.version}&reload=${Date.now()}`; 15930 scriptElement.async = false; 15931 scriptElement.onload = () => { 15932 this.loadedScripts.add('neon-gallery-main'); 15933 resolve(); 15934 }; 15935 scriptElement.onerror = (err) => reject(err); 15936 document.body.appendChild(scriptElement); 15937 }); 15938 15939 for (let i = 0; i < 50; i++) { 15940 if (hasLoadGallerySettings()) { 15941 return true; 15942 } 15943 await new Promise(r => setTimeout(r, 100)); 15944 } 15945 15946 return hasLoadGallerySettings(); 15947 } 15948 15949 /** 15950 * Preload styles only â defer script loading until a gallery load provides a container. 15951 */ 15952 preloadStylesOnly() { 15953 return this.loadStyles(); 15954 } 15955 15956 /** 15957 * Reset initialization tracking when gallery scripts are removed from the DOM 15958 * (e.g. loadPage cleanup). Without this, a pending initPromise never resolves and 15959 * subsequent loadGallery calls hang with isLoading:true, initialized:false. 15960 */ 15961 resetInitializationState() { 15962 console.log('EnhancedGallerySystem: Resetting initialization state'); 15963 this.initPromise = null; 15964 this.initialized = false; 15965 this.isLoading = false; 15966 this.loadedScripts.clear(); 15967 this.loadedStyles.clear(); 15968 // Allow neon-gallery-main bootstrap wipe once again after scripts are re-injected 15969 try { delete window.__neonGalleryMainBootstrapWipeDone; } catch (e) { window.__neonGalleryMainBootstrapWipeDone = false; } 15970 } 15971 15972 /** 15973 * Initialize the gallery system 15974 * @returns {Promise} Resolves when all core dependencies are loaded 15975 */ 15976 initialize() { 15977 // Return existing promise if already initializing or complete 15978 if (this.initPromise) { 15979 return this.initPromise; 15980 } 15981 15982 console.log('EnhancedGallerySystem: Initializing gallery framework'); 15983 this.isLoading = true; 15984 15985 // Create a promise to track loading completion 15986 this.initPromise = (async () => { 15987 try { 15988 // CRITICAL: Initialize neonGalleryState before anything else 15989 // This prevents "Cannot set properties of undefined" errors 15990 if (!window.neonGalleryState) { 15991 window.neonGalleryState = { 15992 initialized: false, 15993 initializing: false, 15994 domInitialized: false, 15995 configApplied: false, 15996 initializationPromise: null 15997 }; 15998 console.log('EnhancedGallerySystem: Initialized neonGalleryState'); 15999 } 16000 16001 // 1. First ensure all essential styles are loaded 16002 await this.loadStyles(); 16003 16004 // 2. Preload all scripts to hint to the browser 16005 this.preloadScripts(); 16006 16007 // 3. Ensure gallery container exists before scripts that require it 16008 this.ensureGalleryContainerPlaceholder( 16009 document.querySelector('.gallery-container') 16010 ); 16011 16012 // 4. Load all essential scripts 16013 await this.loadScripts(); 16014 16015 // 5. Initialize the gallery events 16016 this.setupEvents(); 16017 16018 // Mark as fully initialized 16019 this.initialized = true; 16020 this.isLoading = false; 16021 16022 console.log('EnhancedGallerySystem: Successfully initialized'); 16023 return true; 16024 } catch (error) { 16025 console.error('EnhancedGallerySystem: Initialization failed:', error); 16026 this.isLoading = false; 16027 throw error; 16028 } 16029 })(); 16030 16031 return this.initPromise; 16032 } 16033 16034 /** 16035 * Load all necessary stylesheet resources 16036 */ 16037 async loadStyles() { 16038 console.log('EnhancedGallerySystem: Loading essential stylesheets'); 16039 16040 const stylePromises = this.essentialResources.styles.map(style => { 16041 // Skip if already loaded 16042 if (this.loadedStyles.has(style.id) || document.getElementById(style.id)) { 16043 console.log(`Style already loaded: ${style.id}`); 16044 this.loadedStyles.add(style.id); 16045 return Promise.resolve(); 16046 } 16047 16048 return new Promise((resolve, reject) => { 16049 const link = document.createElement('link'); 16050 link.id = style.id; 16051 link.rel = 'stylesheet'; 16052 // Use proxy URL if flag is set, otherwise use CDN URL 16053 let styleUrl = `${style.href}?v=${this.version}`; 16054 if (window.useCdnProxy && window.convertCdnToProxyUrl && style.href.includes('cdn.neonsky.app')) { 16055 styleUrl = window.convertCdnToProxyUrl(style.href) + '?v=' + this.version; 16056 console.log('ð Using proxy URL for style:', style.id, styleUrl); 16057 } 16058 16059 link.href = styleUrl; 16060 16061 link.onload = () => { 16062 console.log(`â Style loaded: ${style.id}`); 16063 this.loadedStyles.add(style.id); 16064 resolve(); 16065 }; 16066 16067 link.onerror = (err) => { 16068 console.error(`â Failed to load style: ${style.id}`, err); 16069 if (style.href.includes('cdn.neonsky.app') && !styleUrl.includes('/cdn-proxy/')) { 16070 console.error('ð Retrying via proxy:', style.href); 16071 const proxyUrl = window.convertCdnToProxyUrl ? window.convertCdnToProxyUrl(style.href) : style.href.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/'); 16072 link.href = proxyUrl + '?v=' + this.version; 16073 link.onerror = function() { 16074 console.error('â Proxy failed, trying storage:', proxyUrl); 16075 link.href = link.href.replace('cdn.neonsky.app', 'storage.neonsky.app'); 16076 // Don't reject - let it try storage 16077 }; 16078 } else { 16079 reject(new Error(`Failed to load ${style.id}`)); 16080 } 16081 }; 16082 16083 document.head.appendChild(link); 16084 }); 16085 }); 16086 16087 // Wait for all styles to load 16088 await Promise.all(stylePromises); 16089 console.log('EnhancedGallerySystem: All stylesheets loaded'); 16090 } 16091 16092 /** 16093 * Preload scripts to improve browser loading priority 16094 */ 16095 preloadScripts() {
16096 this.essentialResources.scripts.forEach(script => { 16097 // Enhanced check for already preloaded scripts 16098 const scriptName = script.src.split('/').pop(); 16099 const alreadyPreloaded = document.querySelector(`link[rel="preload"][href*="${scriptName}"]`) || 16100 document.querySelector(`script[src*="${scriptName}"]`); 16101 16102 if (alreadyPreloaded) { 16103 console.log(`Script already preloaded or loaded: ${script.id}`); 16104 return; 16105 } 16106 16107 const preload = document.createElement('link'); 16108 preload.rel = 'preload'; 16109 preload.href = `${script.src}?v=${this.version}`; 16110 preload.as = 'script'; 16111 document.head.appendChild(preload); 16112 16113 console.log(`Script preloaded: ${script.id}`); 16114 }); 16115 } 16116 16117 /** 16118 * Load all necessary scripts 16119 */ 16120 async loadScripts() { 16121 console.log('EnhancedGallerySystem: Loading essential scripts'); 16122 16123 // Load sequentially (main before hp) so neon-gallery-main assigns window.neonGalleryUtils 16124 // before hp's initNeonGallery can run; parallel async scripts could execute hp before main finishes. 16125 const loadOne = (script) => { 16126 const alreadyLoaded = this.loadedScripts.has(script.id) || 16127 document.getElementById(script.id) || 16128 document.querySelector(`script[src*="${script.src.split('/').pop()}"]`); 16129 16130 if (alreadyLoaded) { 16131 const mainNeedsReload = 16132 script.id === 'neon-gallery-main' && 16133 typeof window.neonGalleryUtils?.loadGallerySettings !== 'function'; 16134 if (mainNeedsReload) { 16135 console.warn(`EnhancedGallerySystem: ${script.id} in DOM but neonGalleryUtils incomplete â reloading`); 16136 document.querySelectorAll('script[src*="neon-gallery-main"]').forEach(el => { 16137 if (el.parentNode) el.parentNode.removeChild(el); 16138 }); 16139 this.loadedScripts.delete(script.id); 16140 try { delete window.__neonGalleryMainBootstrapWipeDone; } catch (e) { window.__neonGalleryMainBootstrapWipeDone = false; } 16141 } else { 16142 console.log(`Script already loaded: ${script.id}`); 16143 this.loadedScripts.add(script.id); 16144 return Promise.resolve(); 16145 } 16146 } 16147 16148 return new Promise((resolve, reject) => { 16149 const scriptElement = document.createElement('script'); 16150 scriptElement.id = script.id; 16151 16152 let scriptUrl = `${script.src}?v=${this.version}`; 16153 if (window.useCdnProxy && window.convertCdnToProxyUrl && script.src.includes('cdn.neonsky.app')) { 16154 scriptUrl = window.convertCdnToProxyUrl(script.src) + '?v=' + this.version; 16155 console.log('ð Using proxy URL for script:', script.id, scriptUrl); 16156 } 16157 16158 scriptElement.src = scriptUrl; 16159 scriptElement.async = false; 16160 16161 scriptElement.onload = () => { 16162 console.log(`â Script loaded: ${script.id}`); 16163 this.loadedScripts.add(script.id); 16164 resolve(); 16165 }; 16166 16167 scriptElement.onerror = (err) => { 16168 console.error(`â Failed to load script: ${script.id}`, err); 16169 if (script.src.includes('cdn.neonsky.app') && !scriptUrl.includes('/cdn-proxy/')) { 16170 console.error('ð Retrying script via proxy:', script.id); 16171 const proxyUrl = window.convertCdnToProxyUrl ? window.convertCdnToProxyUrl(script.src) : script.src.replace('https://cdn.neonsky.app/', window.location.origin + '/cdn-proxy/'); 16172 scriptElement.src = proxyUrl + '?v=' + this.version; 16173 scriptElement.onerror = function() { 16174 console.error('â Proxy failed, trying storage:', proxyUrl); 16175 scriptElement.src = scriptElement.src.replace('cdn.neonsky.app', 'storage.neonsky.app'); 16176 }; 16177 } else { 16178 reject(new Error(`Failed to load ${script.id}`)); 16179 } 16180 }; 16181 16182 document.body.appendChild(scriptElement); 16183 }); 16184 }; 16185 16186 for (const script of this.essentialResources.scripts) { 16187 await loadOne(script); 16188 } 16189 console.log('EnhancedGallerySystem: All scripts loaded'); 16190 16191 // Save references to original initialization functions after scripts are loaded 16192 if (window.initNeonGallery && typeof window.initNeonGallery === 'function') { 16193 this.originalInitFunctions.initNeonGallery = window.initNeonGallery; 16194 } 16195 16196 if (window.reinitializeGalleryAfterSave && typeof window.reinitializeGalleryAfterSave === 'function') { 16197 this.originalInitFunctions.reinitializeGalleryAfterSave = window.reinitializeGalleryAfterSave; 16198 } 16199 } 16200 16201 /** 16202 * Set up enhanced event listeners 16203 */ 16204 setupEvents() { 16205 // Listen for gallery save events to capture latest state 16206 document.addEventListener('gallery-saved', (event) => { 16207 if (event.detail && event.detail.settings) { 16208 this.captureGalleryState(event.detail.settings); 16209 } 16210 }); 16211 16212 // Override existing functions to capture state changes 16213 this.enhanceExistingFunctions(); 16214 } 16215 16216 /** 16217 * Enhance existing gallery functions to work with our system 16218 */ 16219 enhanceExistingFunctions() { 16220 // Save original functions 16221 const originalInit = window.initNeonGallery; 16222 const originalReinit = window.reinitializeGalleryAfterSave; 16223 16224 // Only enhance if they exist 16225 if (typeof originalInit === 'function') { 16226 // Track if initNeonGallery is currently executing to prevent concurrent calls 16227 let isExecuting = false;
16228 let lastExecutionTime = 0; 16229 16230 window.initNeonGallery = async (...args) => { 16231 // Prevent duplicate initializations within 1 second 16232 const now = Date.now(); 16233 16234 // Check if already executing or called recently 16235 if (isExecuting) { 16236 console.log('EnhancedGallerySystem: initNeonGallery already executing, skipping duplicate call'); 16237 return Promise.resolve(); 16238 } 16239 16240 if (lastExecutionTime && (now - lastExecutionTime) < 1000) { 16241 console.log('EnhancedGallerySystem: Skipping duplicate initNeonGallery call (last call ' + (now - lastExecutionTime) + 'ms ago)'); 16242 return Promise.resolve(); 16243 } 16244 16245 // Set flag and timestamp immediately to prevent concurrent calls 16246 isExecuting = true; 16247 lastExecutionTime = now; 16248 this.lastInitializationTime.set('initNeonGallery', now); 16249 16250 try { 16251 // Call original with all arguments 16252 const result = await originalInit.apply(this, args); 16253 16254 // Capture state after initialization 16255 if (window.neonGalleryConfig) { 16256 this.captureGalleryState(window.neonGalleryConfig); 16257 } 16258 16259 return result; 16260 } finally { 16261 // Clear execution flag after a short delay to allow for async operations 16262 setTimeout(() => { 16263 isExecuting = false; 16264 }, 100); 16265 } 16266 }; 16267 } 16268 16269 if (typeof originalReinit === 'function') { 16270 window.reinitializeGalleryAfterSave = async (savedSettings) => { 16271 // Call original function 16272 const result = await originalReinit(savedSettings); 16273 16274 // Capture updated state 16275 this.captureGalleryState(savedSettings); 16276 16277 return result; 16278 }; 16279 } 16280 } 16281 16282 /** 16283 * Capture current gallery state for potential reuse 16284 */ 16285 captureGalleryState(settings) { 16286 if (!settings) return; 16287 16288 // Store a deep copy of the settings to avoid reference issues 16289 try { 16290 this.previousGalleryState = JSON.parse(JSON.stringify(settings)); 16291 console.log('EnhancedGallerySystem: Captured gallery state for possible reuse'); 16292 } catch (e) { 16293 console.error('EnhancedGallerySystem: Failed to capture gallery state:', e); 16294 } 16295 } 16296 16297 /** 16298 * Load a specific gallery using the enhanced system 16299 * @param {Object} gallery The gallery configuration 16300 * @param {HTMLElement} container The container to load the gallery into 16301 */ 16302 async loadGallery(gallery, container, options) { 16303 if (!gallery) { 16304 console.error('EnhancedGallerySystem: Invalid gallery configuration'); 16305 return false; 16306 } 16307 16308 const forceGalleryReload = options?.forceReload === true || window._forceGalleryReload === true; 16309 16310 // Coalesce concurrent loads for the same gallery â await instead of wiping mid-flight 16311 if (this._loadInProgress && this._loadingGalleryId === gallery.id && !forceGalleryReload) { 16312 const pageViewStillShowing = window._activeContentType === 'page' || 16313 !!document.querySelector('.gallery-container .page-container, .page-container'); 16314 if (!pageViewStillShowing) { 16315 console.log('EnhancedGallerySystem: Awaiting in-flight load for gallery ' + gallery.id); 16316 return this._loadInProgress; 16317 } 16318 console.log('EnhancedGallerySystem: Abandoning standalone in-flight load for page embed', gallery.id); 16319 this._loadInProgress = null; 16320 this._loadingGalleryId = null; 16321 } 16322 16323 const now = Date.now(); 16324 const lastInitTime = this.lastInitializationTime.get(gallery.id); 16325 if (!forceGalleryReload && lastInitTime && (now - lastInitTime) < 1000) { 16326 const hasContent = !!document.querySelector('.gallery-wrapper .masonry-item, .gallery-wrapper .horizontal-scroller, .gallery-wrapper .masonry'); 16327 const pageViewStillShowing = window._activeContentType === 'page' || 16328 !!document.querySelector('.gallery-container .page-container, .page-container'); 16329 if (hasContent && !pageViewStillShowing) { 16330 console.log('EnhancedGallerySystem: Gallery ' + gallery.id + ' recently loaded with content, skipping re-init'); 16331 if (container) container.style.opacity = '1'; 16332 if (typeof window.syncGalleryOptionsButtonVisibility === 'function') { 16333 window.syncGalleryOptionsButtonVisibility(); 16334 } 16335 return true; 16336 } 16337 } 16338 16339 if (forceGalleryReload) { 16340 console.log('EnhancedGallerySystem: force reload requested for gallery ' + gallery.id); 16341 this.lastInitializationTime.delete(gallery.id); 16342 this._loadInProgress = null; 16343 this._loadingGalleryId = null; 16344 } 16345 16346 const loadTask = this._executeGalleryLoad(gallery, container, now, options); 16347 this._loadInProgress = loadTask; 16348 this._loadingGalleryId = gallery.id;
16349 try { 16350 return await loadTask; 16351 } finally { 16352 if (this._loadInProgress === loadTask) { 16353 this._loadInProgress = null; 16354 this._loadingGalleryId = null; 16355 } 16356 } 16357 } 16358 16359 async _executeGalleryLoad(gallery, container, startedAt, options) { 16360 this.lastInitializationTime.set(gallery.id, startedAt); 16361 const contentLoadGeneration = options && options.contentLoadGeneration != null 16362 ? options.contentLoadGeneration 16363 : window.__hydraContentLoadGeneration; 16364 const stillCurrent = () => typeof isHydraContentLoadCurrent === 'function' 16365 ? isHydraContentLoadCurrent(contentLoadGeneration, 'gallery', gallery.id) 16366 : contentLoadGeneration === window.__hydraContentLoadGeneration && 16367 (!window._activeContentType || window._activeContentType === 'gallery') && 16368 (window.activeGalleryId == null || String(window.activeGalleryId) === String(gallery.id)); 16369 16370 stopAutoAdvanceTimer(); 16371 16372 // CRITICAL: Verify container exists 16373 if (!container) { 16374 console.error('EnhancedGallerySystem: Gallery container is null or undefined'); 16375 return false; 16376 } 16377 16378 // Create a unique instance ID for this gallery load (needed before script init) 16379 const galleryInstanceId = Date.now(); 16380 16381 try { 16382 // Clean up previous gallery state and prepare container BEFORE loading scripts 16383 this.cleanupGalleryState(container); 16384 const containerSetup = this.setupCorrectGalleryContainer(container); 16385 if (!containerSetup) { 16386 throw new Error('Failed to set up gallery container'); 16387 } 16388 this.setupGalleryParameters(gallery, galleryInstanceId); 16389 } catch (prepError) { 16390 console.error('EnhancedGallerySystem: Failed to prepare gallery container:', prepError); 16391 return false; 16392 } 16393 16394 // Make sure the system is initialized (scripts load with container already in DOM) 16395 try { 16396 await this.initialize(); 16397 } catch (error) { 16398 console.error('EnhancedGallerySystem: Failed to initialize gallery framework:', error); 16399 //this.showErrorMessage(container, 'Failed to initialize gallery system. Please try again later.'); 16400 return false; 16401 } 16402 16403 if (!stillCurrent()) { 16404 console.log('EnhancedGallerySystem: Aborting stale load after framework initialize for', gallery.id); 16405 return false; 16406 } 16407 16408 if (!(await this.ensureNeonGalleryUtilsReady(container))) { 16409 console.error('EnhancedGallerySystem: neonGalleryUtils.loadGallerySettings unavailable after script load'); 16410 return false; 16411 } 16412 16413 if (!stillCurrent()) { 16414 console.log('EnhancedGallerySystem: Aborting stale load after utils ready for', gallery.id); 16415 return false; 16416 } 16417 16418 // Update current gallery tracking 16419 this.currentGalleryId = gallery.id; 16420 16421 console.log(`EnhancedGallerySystem: Loading gallery ${gallery.title} (ID: ${gallery.id}, Instance: ${galleryInstanceId})`); 16422 16423 try { 16424 // Determine which initialization method to use 16425 if (this.canUseReinitForGallery(gallery, options)) { 16426 const result = await this.reinitializeGallery(gallery, container, options); 16427 return stillCurrent() ? result : false;
16428 } else { 16429 const result = await this.initializeNewGallery(gallery, container, galleryInstanceId, options); 16430 return stillCurrent() ? result : false; 16431 } 16432 } catch (error) { 16433 console.error('EnhancedGallerySystem: Error loading gallery:', error); 16434 if (container) { 16435 console.error('EnhancedGallerySystem: Error loading gallery:', error); 16436 //this.showErrorMessage(container, 'There was a problem loading the gallery. Please try again later.'); 16437 } 16438 return false; 16439 } 16440} 16441 16442 normalizeGalleryIdentityValue(value) { 16443 if (value === undefined || value === null || value === '') return ''; 16444 return String(value); 16445 } 16446 16447 previousGalleryStateBelongsTo(gallery, previousState) { 16448 if (!gallery || !previousState) return false; 16449 16450 const targetGalleryId = this.normalizeGalleryIdentityValue(gallery.id); 16451 const targetPageId = this.normalizeGalleryIdentityValue(gallery.pageId); 16452 if (!targetGalleryId && !targetPageId) return false; 16453 16454 const previousGalleryId = this.normalizeGalleryIdentityValue( 16455 previousState.galleryId || previousState.loadedGalleryId 16456 ); 16457 const previousPageId = this.normalizeGalleryIdentityValue( 16458 previousState.pageId || previousState.initialPageUuid 16459 ); 16460 16461 if (!previousGalleryId && !previousPageId) return false; 16462 if (previousGalleryId && targetGalleryId && previousGalleryId !== targetGalleryId) { 16463 return false; 16464 } 16465 if (previousPageId && targetPageId && previousPageId !== targetPageId) { 16466 return false; 16467 } 16468 16469 return ( 16470 (previousGalleryId && targetGalleryId && previousGalleryId === targetGalleryId) || 16471 (previousPageId && targetPageId && previousPageId === targetPageId) 16472 ); 16473 } 16474 16475 /** 16476 * Determine if we can use reinitialization for this gallery 16477 */ 16478 canUseReinitForGallery(gallery, options) { 16479 if (options?.forceReload === true || window._forceGalleryReload === true) { 16480 return false; 16481 } 16482 // Check if reinitializeGalleryAfterSave function exists 16483 if (typeof window.reinitializeGalleryAfterSave !== 'function') { 16484 return false; 16485 } 16486 16487 // Check if we have a previous state to reuse 16488 if (!this.previousGalleryState) { 16489 return false; 16490 } 16491 16492 if (!this.previousGalleryStateBelongsTo(gallery, this.previousGalleryState)) { 16493 console.log('EnhancedGallerySystem: Cannot use reinit - previous gallery state does not match target gallery'); 16494 return false; 16495 } 16496 16497 // Check if the gallery has the necessary properties for reinit 16498 if (!gallery.pageId || !gallery.galleryOptions) { 16499 return false; 16500 } 16501 16502 // CRITICAL: Check if the gallery wrapper actually exists in the DOM 16503 // Reinitialization requires an existing gallery structure 16504 const galleryWrapper = document.querySelector('.gallery-wrapper'); 16505 const neonContainer = document.getElementById('neon-gallery-container'); 16506 if (!galleryWrapper || !neonContainer) { 16507 console.log('EnhancedGallerySystem: Cannot use reinit - gallery wrapper or container not found'); 16508 return false; 16509 } 16510 16511 return true; 16512 } 16513 16514 /** 16515 * Reinitialize a gallery using the reinitialization process 16516 */ 16517async reinitializeGallery(gallery, container, options) { 16518 console.log('EnhancedGallerySystem: Using reinitialization process for gallery', gallery.id); 16519 16520 try { 16521 // CRITICAL: Ensure CSS is loaded before attempting reinitialization 16522 ensureGalleryStylesLoaded(); 16523 16524 // We should already have the proper container, but just in case: 16525 if (!document.getElementById('neon-gallery-container')) { 16526 this.setupCorrectGalleryContainer(container); 16527 } 16528 16529 // CRITICAL: Ensure config is set up with all required fields before reinitialization 16530 const galleryOptions = gallery.galleryOptions || {}; 16531 window.neonGalleryConfig = { 16532 useData: true, 16533 useCDN: true, 16534 version: 'live', 16535 manualCollectionName: galleryOptions.manualCollectionName || "mod", 16536 layoutType: galleryOptions.layoutType || "grid", 16537 ...galleryOptions, 16538 siteId: window.siteId || gallery.siteId || '', 16539 galleryId: gallery.id, 16540 // Explicitly include all permanent storage fields 16541 isPerma: galleryOptions.isPerma, 16542 permaURL: galleryOptions.permaURL, 16543 loadTxId: galleryOptions.loadTxId, 16544 loadPermaURL: galleryOptions.loadPermaURL, 16545 containerSelector: '#neon-gallery-container' 16546 }; 16547 16548 // Prepare settings by merging previous state with new gallery options. 16549 // Identity fields always come from the menu target, never leftover snapshot ids. 16550 const settings = { 16551 ...this.previousGalleryState, 16552 ...gallery.galleryOptions, 16553 pageId: gallery.pageId, 16554 initialPageUuid: gallery.pageId, 16555 galleryId: gallery.id, 16556 loadedGalleryId: gallery.id, 16557 forceReload: true, 16558 containerSelector: '#neon-gallery-container' // Add this explicitly 16559 }; 16560 16561 // Call reinitializeGalleryAfterSave with the merged settings 16562 window._reinitializeGalleryFromSystem = true; 16563 let result; 16564 try { 16565 result = await window.reinitializeGalleryAfterSave(settings); 16566 } finally { 16567 window._reinitializeGalleryFromSystem = false;
16568 } 16569 16570 if (result) { 16571 console.log('EnhancedGallerySystem: Gallery reinitialization completed successfully'); 16572 // Fade in the container 16573 container.style.opacity = '1'; 16574 return true; 16575 } else { 16576 throw new Error('Reinitialization did not complete successfully'); 16577 } 16578 } catch (error) { 16579 console.error('EnhancedGallerySystem: Error reinitializing gallery:', error); 16580 // Fall back to standard initialization - ensure config is set up properly 16581 const galleryInstanceId = Date.now(); 16582 // Re-setup parameters and config for the fallback 16583 this.setupGalleryParameters(gallery, galleryInstanceId); 16584 return this.initializeNewGallery(gallery, container, galleryInstanceId, options); 16585 } 16586} 16587 16588 /** 16589 * Initialize a gallery from scratch (fallback method) 16590 */ 16591 async initializeNewGallery(gallery, container, galleryInstanceId, options) { 16592 console.log('EnhancedGallerySystem: Using standard initialization for gallery', gallery.id); 16593 const contentLoadGeneration = options && options.contentLoadGeneration != null 16594 ? options.contentLoadGeneration 16595 : window.__hydraContentLoadGeneration; 16596 const stillCurrent = () => typeof isHydraContentLoadCurrent === 'function' 16597 ? isHydraContentLoadCurrent(contentLoadGeneration, 'gallery', gallery.id) 16598 : (!window._activeContentType || window._activeContentType === 'gallery') && 16599 (window.activeGalleryId == null || String(window.activeGalleryId) === String(gallery.id)); 16600 16601 try { 16602 // CRITICAL: Ensure CSS is loaded BEFORE creating any gallery elements 16603 ensureGalleryStylesLoaded(); 16604 16605 // Clean up container 16606 if (container) { 16607 container.innerHTML = ''; 16608 } else { 16609 console.error('EnhancedGallerySystem: Container is null in initializeNewGallery'); 16610 return false; 16611 } 16612 16613 // Create gallery container with unique ID 16614 const galleryContainerId = `gallery-container-${gallery.id}-${galleryInstanceId}`; 16615 const neonGalleryContainer = document.createElement('div'); 16616 neonGalleryContainer.id = 'neon-gallery-container'; 16617 neonGalleryContainer.className = 'gallery-direct-content'; 16618 neonGalleryContainer.setAttribute('data-gallery-id', gallery.id.toString()); 16619 neonGalleryContainer.setAttribute('data-instance-id', galleryInstanceId.toString()); 16620 neonGalleryContainer.setAttribute('data-unique-id', galleryContainerId); 16621 neonGalleryContainer.style.width = '100%'; 16622 neonGalleryContainer.style.height = '100%'; 16623 container.appendChild(neonGalleryContainer); 16624 16625 // CRITICAL: Initialize neonGalleryState if it doesn't exist 16626 if (!window.neonGalleryState) { 16627 window.neonGalleryState = { 16628 initialized: false, 16629 initializing: false, 16630 domInitialized: false, 16631 configApplied: false, 16632 initializationPromise: null 16633 }; 16634 } 16635 16636 // CRITICAL: Always ensure neonGalleryConfig is properly set with ALL required fields 16637 // Don't rely on conditional logic - always set it correctly here 16638 const galleryOptions = gallery.galleryOptions || {}; 16639 window.neonGalleryConfig = { 16640 useData: true, 16641 useCDN: true, 16642 version: 'live', 16643 manualCollectionName: galleryOptions.manualCollectionName || "mod", 16644 layoutType: galleryOptions.layoutType || "grid", 16645 ...galleryOptions, 16646 siteId: window.siteId || gallery.siteId || '', 16647 galleryId: gallery.id, 16648 galleryInstanceId: galleryInstanceId, 16649 containerSelector: '#neon-gallery-container', 16650 // Explicitly include all permanent storage fields (critical for proper initialization) 16651 isPerma: galleryOptions.isPerma, 16652 permaURL: galleryOptions.permaURL, 16653 loadTxId: galleryOptions.loadTxId, 16654 loadPermaURL: galleryOptions.loadPermaURL 16655 }; 16656 16657 // Call the initialization function directly if available 16658 if (typeof window.initNeonGallery === 'function') { 16659 console.log('EnhancedGallerySystem: Calling direct initialization method'); 16660 16661 // Additional check: prevent duplicate initNeonGallery calls within 1 second 16662 const initKey = 'init_' + gallery.id + '_' + galleryInstanceId; 16663 const lastInitCall = this.lastInitializationTime.get(initKey); 16664 const now = Date.now(); 16665 if (lastInitCall && (now - lastInitCall) < 1000) { 16666 console.log('EnhancedGallerySystem: Skipping duplicate initNeonGallery call for gallery ' + gallery.id + '
16666(last call ' + (now - lastInitCall) + 'ms ago)'); 16667 container.style.opacity = '1'; 16668 return true; 16669 } 16670 this.lastInitializationTime.set(initKey, now); 16671 16672 try { 16673 // Wait a tiny bit to ensure DOM updates have propagated 16674 await new Promise(resolve => setTimeout(resolve, 10)); 16675 if (!stillCurrent()) { 16676 console.log('EnhancedGallerySystem: Aborting stale initializeNewGallery before initNeonGallery'); 16677 return false; 16678 } 16679 16680 const result = await window.initNeonGallery(); 16681 if (!stillCurrent()) { 16682 console.log('EnhancedGallerySystem: Ignoring stale initializeNewGallery completion'); 16683 return false; 16684 } 16685 console.log('EnhancedGallerySystem: Direct initialization successful'); 16686 16687 // Save state for future loads 16688 if (window.neonGalleryConfig) { 16689 this.captureGalleryState(window.neonGalleryConfig); 16690 } 16691 16692 // Fade in the container 16693 container.style.opacity = '1'; 16694 return true; 16695 } catch (e) { 16696 if (!stillCurrent()) { 16697 console.log('EnhancedGallerySystem: Ignoring stale direct initialization error'); 16698 return false; 16699 } 16700 console.error('EnhancedGallerySystem: Direct initialization failed:', e); 16701 throw e; 16702 } 16703 } else { 16704 // If initNeonGallery isn't available, use event-based initialization 16705 console.log('EnhancedGallerySystem: Using event-based initialization fallback'); 16706 16707 // Create an event to trigger initialization 16708 const initEvent = new CustomEvent('neon-gallery-init', { 16709 detail: { 16710 galleryId: gallery.id, 16711 instanceId: galleryInstanceId, 16712 containerId: galleryContainerId 16713 } 16714 }); 16715 document.dispatchEvent(initEvent); 16716 16717 // Wait a short time to see if initialization completes 16718 await new Promise(resolve => setTimeout(resolve, 500)); 16719 if (!stillCurrent()) { 16720 console.log('EnhancedGallerySystem: Ignoring stale event-based gallery initialization'); 16721 return false; 16722 } 16723 16724 // Fade in regardless of result - gallery script should handle displaying errors 16725 container.style.opacity = '1'; 16726 return true; 16727 } 16728 } catch (error) { 16729 console.error('EnhancedGallerySystem: Error in standard initialization:', error); 16730 if (!stillCurrent()) { 16731 console.log('EnhancedGallerySystem: Ignoring stale standard initialization error'); 16732 return false; 16733 } 16734 if (container) { 16735 this.showErrorMessage(container, 'Failed to load gallery content. Please try again later.'); 16736 } 16737 return false; 16738 } 16739 } 16740 16741 /** 16742 * Clean up gallery state without removing loaded scripts 16743 */ 16744 cleanupGalleryState(container) { 16745 console.log('EnhancedGallerySystem: Cleaning up gallery state'); 16746 16747 // Stop any ongoing singles autoplay from the previous gallery 16748 if (typeof window.stopSinglesAutoplay === 'function') { 16749 try { 16750 window.stopSinglesAutoplay(); 16751 console.log('EnhancedGallerySystem: Called stopSinglesAutoplay() during cleanup.'); 16752 } catch (error) { 16753 console.warn('EnhancedGallerySystem: stopSinglesAutoplay() failed during cleanup:', error); 16754 } 16755 } else { 16756 console.warn('EnhancedGallerySystem: stopSinglesAutoplay() function not found during cleanup.'); 16757 } 16758 16759 // 1. Reset global gallery state variables 16760 const resetGlobals = [ 16761 'neonGalleryInitComplete', 'neonGalleryInitInProgress', 16762 'neonGalleryLoaded', 'neonGalleryConfig', 'neonGalleryCache', 'neonGalleryImages', 16763 'neonGallerySettings', 'neonLightbox', 'neonGalleryEventListeners', 16764 'neonGalleryInstance', 'currentGalleryId', 'galleryData', 16765 'imageCache', 'thumbnailCache' 16766 ]; 16767
16768 resetGlobals.forEach(prop => { 16769 if (window[prop] !== undefined) { 16770 window[prop] = null; 16771 } 16772 }); 16773 16774 // 2. Reset neonGalleryState if it's a proper object with properties 16775 if (window.neonGalleryState && typeof window.neonGalleryState === 'object') { 16776 window.neonGalleryState = { 16777 initialized: false, 16778 initializing: false, 16779 domInitialized: false, 16780 configApplied: false, 16781 initializationPromise: null 16782 }; 16783 } 16784 16785 // 3. Remove lightbox elements and overlays 16786 document.querySelectorAll('#neon-lightbox, .neon-lightbox, .lightbox-container').forEach(el => { 16787 if (el && el.parentNode) { 16788 el.parentNode.removeChild(el); 16789 } 16790 }); 16791 16792 const staleOptionsOverlay = document.getElementById('optionsOverlay'); 16793 if (staleOptionsOverlay && staleOptionsOverlay.parentNode) { 16794 staleOptionsOverlay.parentNode.removeChild(staleOptionsOverlay); 16795 } 16796 window.optionsVisible = false; 16797 16798 // 4. Remove any gallery-specific DOM elements 16799 document.querySelectorAll( 16800 '.neon-gallery-wrapper, .fullscreen-overlay, ' + 16801 '.neon-gallery-modal, .gallery-tooltip, .neon-gallery-context-menu, ' + 16802 '.neon-thumbnails, .neon-image, .neon-caption, ' + 16803 '.neon-controls, .neon-pagination' 16804 ).forEach(el => { 16805 if (el && el.parentNode) { 16806 el.parentNode.removeChild(el); 16807 } 16808 }); 16809 16810 // 5. Clear container if provided and valid 16811 if (container) { 16812 container.innerHTML = ''; 16813 container.style.opacity = '0'; 16814 } 16815 16816 // 6. Clear keyboard listeners to prevent multiple listeners from different galleries 16817 if (typeof window.removeAllLightboxKeyboardListeners === 'function') { 16818 window.removeAllLightboxKeyboardListeners(); 16819 console.log('EnhancedGallerySystem: Cleared all lightbox keyboard listeners during cleanup'); 16820 } else if (window.currentLightboxKeyboardListener) { 16821 document.removeEventListener('keydown', window.currentLightboxKeyboardListener); 16822 window.currentLightboxKeyboardListener = null; 16823 console.log('EnhancedGallerySystem: Cleared keyboard listeners during cleanup'); 16824 } 16825 window.lightboxKeyboardListenerActive = false; 16826 } 16827 16828 /** 16829 * Prepare container for gallery loading 16830 */ 16831 prepareContainer(container, gallery, instanceId) { 16832 // CRITICAL: Add null check for container 16833 if (!container) { 16834 console.error('EnhancedGallerySystem: Container is null in prepareContainer'); 16835 return; 16836 } 16837 16838 try { 16839 // Add attributes and data 16840 container.setAttribute('data-gallery-id', gallery.id.toString()); 16841 container.setAttribute('data-instance-id', instanceId.toString()); 16842 16843 // Add loading indicator 16844 const loadingIndicator = document.createElement('div'); 16845 loadingIndicator.className = 'gallery-loading'; 16846 loadingIndicator.innerHTML = ` 16847 <div class="spinner"></div> 16848 <div class="loading-text">Loading Gallery...</div> 16849 `; 16850 16851 container.appendChild(loadingIndicator); 16852 } catch (error) { 16853 console.error('EnhancedGallerySystem: Error preparing container:', error); 16854 } 16855 } 16856 16857 16858 16859 16860setupGalleryParameters(gallery, galleryInstanceId) { 16861 // Set up gallery parameters with enhanced uniqueness 16862 window.Parameters = window.Parameters || {}; 16863 16864 // Clone and extend existing Parameters to avoid losing important values 16865 const existingParams = { ...window.Parameters }; 16866 16867 window.Parameters = { 16868 ...existingParams, 16869 SiteAlias: window.location.hostname, 16870 InitialPageUuid: gallery.pageId, 16871 InitialPageAlias: gallery.slug || this.slugify(gallery.title), 16872 // CRITICAL FIX: Set isInEditor to true if user is logged in as admin OR in edit mode 16873 // This ensures gallery files bypass cache for any logged-in admin, not just when edit mode is active 16874 isInEditor: window.isEditing || 16875 localStorage.getItem('hydra_is_admin') === 'true' || 16876 window.isAdmin === true || 16877 document.body.classList.contains('hydra-admin'), 16878 siteId: window.siteId || gallery.siteId || '', 16879 isHydra: true, 16880 16881 // CRITICAL: These properties help the gallery script find the container 16882 galleryContainerId: 'neon-gallery-container', 16883 containerSelector: '#neon-gallery-container', 16884 16885 // Additional properties 16886 galleryInstanceId: galleryInstanceId, 16887 galleryUniqueId: `gallery-container-${gallery.id}-${galleryInstanceId}`, 16888 loadTimestamp: galleryInstanceId, 16889 loadedGalleryId: gallery.id 16890 }; 16891 16892 // Set up gallery config - CRITICAL: Include all permanent storage fields 16893 const galleryOptions = gallery.galleryOptions || {}; 16894 window.neonGalleryConfig = { 16895 useData: true, 16896 useCDN: true, 16897 version: 'live', 16898 manualCollectionName: galleryOptions.manualCollectionName || "mod", 16899 layoutType: galleryOptions.layoutType || "grid", 16900 ...galleryOptions, 16901 siteId: window.siteId || gallery.siteId || '', 16902 galleryId: gallery.id, 16903 galleryInstanceId: galleryInstanceId, 16904 16905 // Add container selector here too 16906 containerSelector: '#neon-gallery-container', 16907 16908 // CRITICAL: Explicitly include all permanent storage fields to ensure they're available 16909 // These are needed for proper gallery initialization and data loading 16910 isPerma: galleryOptions.isPerma, 16911 permaURL: galleryOptions.permaURL, 16912 loadTxId: galleryOptions.loadTxId, 16913 loadPermaURL: galleryOptions.loadPermaURL 16914 }; 16915} 16916 16917 /** 16918 * Show error message in container 16919 */ 16920 showErrorMessage(container, message) {
16921 // CRITICAL: Add null check for container 16922 if (!container) { 16923 console.error('EnhancedGallerySystem: Container is null in showErrorMessage'); 16924 return; 16925 } 16926 16927 try { 16928 // Clear container 16929 container.innerHTML = ''; 16930 16931 // Create error message element 16932 const errorElement = document.createElement('div'); 16933 errorElement.className = 'gallery-error'; 16934 errorElement.innerHTML = ` 16935 <h3>Error Loading Gallery</h3> 16936 <p>${message}</p> 16937 `; 16938 container.appendChild(errorElement); 16939 container.style.opacity = '1'; 16940 } catch (error) { 16941 console.error('EnhancedGallerySystem: Error showing error message:', error); 16942 } 16943 } 16944 16945 /** 16946 * Utility function to convert title to slug 16947 */ 16948 slugify(text) { 16949 return text 16950 .toLowerCase() 16951 .replace(/s+/g, '-') 16952 .replace(/[^w-]+/g, '') 16953 .replace(/--+/g, '-') 16954 .trim(); 16955 } 16956 } 16957 16958 // Create a singleton instance 16959 window.EnhancedGallerySystem = window.EnhancedGallerySystem || new EnhancedGallerySystem(); 16960 16961 /** 16962 * Enhanced loadGallery function using the EnhancedGallerySystem 16963 * This replaces the existing loadGallery function 16964 */ 16965const enhancedLoadGallery = async function(id, event, options) { 16966 const originalLoadGallery = window.originalLoadGallery; 16967 const forceGalleryReload = options?.forceReload === true || window._forceGalleryReload === true; 16968 16969 stopAutoAdvanceTimer(); 16970 if (typeof dismissGalleryPasswordOverlay === 'function') { 16971 dismissGalleryPasswordOverlay(); 16972 } 16973 16974 if (event && event.target && event.target.isConnected === false) { 16975 console.log('enhancedLoadGallery: Ignoring navigation from detached element'); 16976 return; 16977 } 16978 16979 if (event) { 16980 event.preventDefault(); 16981 event.stopPropagation(); 16982 } 16983 16984 let galleryContainer = document.querySelector('.gallery-container'); 16985 16986 if (!galleryContainer) { 16987 galleryContainer = document.getElementById('galleryFrame')?.parentElement || 16988 document.getElementById('main-content') || 16989 document.getElementById('content'); 16990 16991 if (!galleryContainer) { 16992 console.error('Gallery container not found, falling back to original function'); 16993 if (typeof originalLoadGallery === 'function') { 16994 return originalLoadGallery(id, event, options); 16995 } else { 16996 console.error('Gallery container not found and no fallback available'); 16997 return; 16998 } 16999 } 17000 } 17001 17002 // Bail out before hiding/clearing when the same gallery is already loading or rendered. 17003 // Post-save passes { forceReload: true } to bypass this path without affecting navigation. 17004 const egEarly = window.EnhancedGallerySystem; 17005 if (egEarly && !forceGalleryReload) { 17006 if (egEarly._loadInProgress && egEarly._loadingGalleryId === id) { 17007 const pageViewStillShowing = window._activeContentType === 'page' || 17008 !!document.querySelector('.gallery-container .page-container, .page-container'); 17009 if (pageViewStillShowing) { 17010 console.log('enhancedLoadGallery: abandoning standalone in-flight load for page view', id); 17011 egEarly._loadInProgress = null; 17012 egEarly._loadingGalleryId = null; 17013 } else { 17014 console.log('enhancedLoadGallery: awaiting in-flight load for gallery', id); 17015 await egEarly._loadInProgress; 17016 if (galleryContainer) galleryContainer.style.opacity = '1'; 17017 return; 17018 } 17019 } 17020 const hasRenderedGallery = !!document.querySelector( 17021 '.gallery-wrapper .masonry-item, .gallery-wrapper .horizontal-scroller, .gallery-wrapper .masonry' 17022 ); 17023 // Embedded galleries on a page share currentGalleryId; that is not a standalone gallery view. 17024 const pageViewStillShowing = window._activeContentType === 'page' || 17025 !!document.querySelector('.gallery-container .page-container, .page-container'); 17026 if (hasRenderedGallery && egEarly.currentGalleryId === id && !pageViewStillShowing) { 17027 console.log('enhancedLoadGallery: gallery already rendered, skipping reload'); 17028 if (galleryContainer) galleryContainer.style.opacity = '1'; 17029 if (typeof window.syncGalleryOptionsButtonVisibility === 'function') { 17030 window.syncGalleryOptionsButtonVisibility(); 17031 } 17032 return; 17033 } 17034 } else if (forceGalleryReload) { 17035 console.log('enhancedLoadGallery: force reload requested, bypassing already-rendered skip'); 17036 if (egEarly?.lastInitializationTime?.delete) { 17037 egEarly.lastInitializationTime.delete(id); 17038 } 17039 if (egEarly) { 17040 egEarly._loadInProgress = null; 17041 egEarly._loadingGalleryId = null; 17042 } 17043 } 17044 17045 if (galleryContainer) { 17046 galleryContainer.style.opacity = '0'; 17047 } 17048 17049 console.log('Loading gallery with ID (enhancedLoadGallery):', id); 17050 17051 const gallery = findGalleryById(galleries, id); 17052 17053 if (!gallery) { 17054 console.warn('No gallery found with ID:', id); 17055 if (galleryContainer) { 17056 galleryContainer.innerHTML = ` 17057 <div class="gallery-error"> 17058 <h3>Gallery Not Found</h3> 17059 <p>The requested gallery could not be found.</p> 17060 </div> 17061 `; 17062 galleryContainer.style.opacity = '1'; 17063 } 17064 return; 17065 } 17066 17067 if (isPageMenuItem(gallery)) { 17068 return typeof window.loadPage === 'function' ? window.loadPage(id, event) : loadPage(id, event); 17069 } 17070 17071 window._activeContentType = 'gallery'; 17072 17073 if (gallery.isSubmenu) { 17074 console.log('Gallery is submenu, skipping URL update'); 17075 toggleSubmenu(id, event || { stopPropagation: () => {} }); 17076 if (galleryContainer){ 17077 galleryContainer.style.opacity = '1'; 17078 } 17079 return; 17080 } 17081 17082 const contentLoadGeneration = beginHydraContentLoad('gallery', gallery.id, gallery.pageId); 17083 if (window.PageManager && typeof window.PageManager.clearCurrentPage === 'function') { 17084 window.PageManager.clearCurrentPage(); 17085 } 17086 17087 console.log('Found gallery:', gallery.title); 17088 17089 // Check for gallery-level password protection (only for visitors, not in edit mode) 17090 if (!isInEditMode() && gallery.passwordProtected) { 17091 // Use PageManager's gallery password protection methods 17092 if (window.PageManager && typeof window.PageManager.checkGalleryPasswordAccess === 'function' && 17093 typeof window.PageManager.createGalleryPasswordOverlay === 'function') { 17094 17095 if (!window.PageManager.checkGalleryPasswordAccess(gallery)) { 17096 console.log('Gallery is password protected, showing overlay'); 17097 window.PageManager.createGalleryPasswordOverlay(gallery, (accessGranted) => { 17098 if (accessGranted) { 17099 // Reload the gallery now that access is granted 17100 enhancedLoadGallery(id, event); 17101 } 17102 }); 17103 return; // Exit early, will reload after password is entered 17104 } 17105 } 17106 } 17107 17108 window.activeGalleryId = id; 17109 activeGalleryId = id; 17110 17111 // CRITICAL: Clean up page-embedded gallery scripts/DOM before loading a standalone gallery. 17112 // loadPage() calls removeGalleryScriptsWithPause which can interrupt EnhancedGallerySystem.initialize(), 17113 // leaving isLoading:true with a never-resolving initPromise. Re-run cleanup here (matching original 17114 // loadGallery) and clear the container so page content does not block gallery rendering. 17115 if (typeof window.removeGalleryScriptsWithPause === 'function') { 17116 await window.removeGalleryScriptsWithPause(); 17117 } 17118 if (!isHydraContentLoadCurrent(contentLoadGeneration, 'gallery', gallery.id)) { 17119 console.log('enhancedLoadGallery: Aborting stale load after cleanup for', gallery.id); 17120 return; 17121 } 17122 if (galleryContainer) { 17123 galleryContainer.innerHTML = ''; 17124 } 17125 17126 const bodyEl = document.body; 17127 if (shouldApplyMenuHiddenForEmbedOrGallery(gallery)) { 17128 bodyEl.classList.add('menu-hidden-on-page'); 17129 } else { 17130 bodyEl.classList.remove('menu-hidden-on-page'); 17131 } 17132 applyMenuOverlayClassForGallery(gallery); 17133 17134 try { 17135 if (typeof updateActiveStates === 'function') updateActiveStates(); 17136 if (typeof updateMobileTitle === 'function') updateMobileTitle(); 17137 if (typeof closeMobileMenu === 'function') closeMobileMenu(); 17138 // Close the gallery options panel when navigating to a different gallery 17139 if (typeof window.closeOptionsPanel === 'function' && !forceGalleryReload) { 17140 window.closeOptionsPanel(); 17141 } 17142 const staleOptionsOverlay = document.getElementById('optionsOverlay'); 17143 if (staleOptionsOverlay) { 17144 staleOptionsOverlay.remove(); 17145 } 17146 window.optionsVisible = false;
17147 if (typeof updateURLWithGallerySlug === 'function') { 17148 updateURLWithGallerySlug(gallery); 17149 } 17150 } catch (uiError) { 17151 console.error('Error updating UI state:', uiError); 17152 } 17153 17154 try { 17155 // Add detailed logging about EnhancedGallerySystem availability 17156 console.log('ð Checking EnhancedGallerySystem availability:', { 17157 exists: !!window.EnhancedGallerySystem, 17158 hasLoadGallery: !!(window.EnhancedGallerySystem && typeof window.EnhancedGallerySystem.loadGallery === 'function'), 17159 initialized: !!(window.EnhancedGallerySystem && window.EnhancedGallerySystem.initialized), 17160 isLoading: !!(window.EnhancedGallerySystem && window.EnhancedGallerySystem.isLoading) 17161 }); 17162 17163 if (window.EnhancedGallerySystem && typeof window.EnhancedGallerySystem.loadGallery === 'function') { 17164 console.log('â EnhancedGallerySystem available, calling loadGallery'); 17165 const result = await window.EnhancedGallerySystem.loadGallery( 17166 gallery, 17167 galleryContainer, 17168 { 17169 ...(options || {}), 17170 ...(forceGalleryReload ? { forceReload: true } : {}), 17171 contentLoadGeneration 17172 } 17173 ); 17174 if (!isHydraContentLoadCurrent(contentLoadGeneration, 'gallery', gallery.id)) { 17175 console.log('enhancedLoadGallery: Ignoring stale gallery completion for', gallery.id); 17176 return; 17177 } 17178 console.log('ð EnhancedGallerySystem.loadGallery result:', result); 17179 17180 if (!result && typeof originalLoadGallery === 'function') { 17181 console.log('â ï¸ Enhanced gallery loading failed, falling back to original function'); 17182 return originalLoadGallery(id, event, options); 17183 } 17184 if (galleryContainer) { 17185 galleryContainer.style.opacity = '1'; 17186 } 17187 if (typeof window.syncGalleryOptionsButtonVisibility === 'function') { 17188 window.syncGalleryOptionsButtonVisibility(); 17189 setTimeout(window.syncGalleryOptionsButtonVisibility, 350); 17190 } 17191 } else { 17192 console.error('â EnhancedGallerySystem or its loadGallery method is not available.', { 17193 EnhancedGallerySystem: !!window.EnhancedGallerySystem, 17194 loadGallery: !!(window.EnhancedGallerySystem && typeof window.EnhancedGallerySystem.loadGallery === 'function'), 17195 useCdnProxy: window.useCdnProxy || false 17196 }); 17197 17198 // Fallback: Try using createGalleryContext directly 17199 console.log('ð Attempting fallback: createGalleryContext'); 17200 if (typeof createGalleryContext === 'function') { 17201 createGalleryContext(gallery, galleryContainer); 17202 if (galleryContainer) { 17203 galleryContainer.style.opacity = '1'; 17204 } 17205 return; 17206 } 17207 17208 if (galleryContainer) { 17209 galleryContainer.innerHTML = `<div class="gallery-error">Gallery system not available. EnhancedGallerySystem: ${!!window.EnhancedGallerySystem}, loadGallery: ${!!(window.EnhancedGallerySystem && typeof window.EnhancedGallerySystem.loadGallery === 'function')}</div>`; 17210 galleryContainer.style.opacity = '1'; 17211 } 17212 return; 17213 } 17214 } catch (error) { 17215 console.error('Error loading gallery:', error); 17216 if (!isHydraContentLoadCurrent(contentLoadGeneration, 'gallery', gallery.id)) { 17217 console.log('enhancedLoadGallery: Ignoring stale gallery error for', gallery.id); 17218 return; 17219 } 17220 if (galleryContainer) { 17221 try { 17222 galleryContainer.innerHTML = ` 17223 <div class="gallery-error"> 17224 <h3>Error Loading Gallery</h3> 17225 <p>There was a problem loading the gallery. Please try again later.</p> 17226 <p class="error-details">${error.message}</p> 17227 </div> 17228 `; 17229 galleryContainer.style.opacity = '1'; 17230 } catch (containerError) { 17231 console.error('Error showing error message in container:', containerError); 17232 } 17233 } 17234 if (typeof originalLoadGallery === 'function') { 17235 console.log('Falling back to original loadGallery after error'); 17236 return originalLoadGallery(id, event, options); 17237 } 17238 } 17239 }; 17240 17241 /** 17242 * Initialization function to set up the gallery system 17243 */ 17244 function initializeGallerySystem() { 17245 console.log('Initializing enhanced gallery system'); 17246 17247 // Store reference to original function before replacing 17248 if (typeof window.loadGallery === 'function') { 17249 window.originalLoadGallery = window.loadGallery; 17250 } 17251 17252 // Replace the existing loadGallery function with our optimized version 17253 window.loadGallery = enhancedLoadGallery; 17254 17255 // Add this CSS for loading/error states 17256 addGallerySystemStyles(); 17257 17258 // Preload styles only at boot â scripts load when first gallery opens (with container ready) 17259 window.EnhancedGallerySystem.preloadStylesOnly().catch(error => { 17260 console.warn('Gallery styles preloading failed:', error); 17261 }); 17262 } 17263 17264 /** 17265 * Add CSS styles for loading indicators and error messages 17266 */ 17267 function addGallerySystemStyles() { 17268 const styleId = 'enhanced-gallery-system-styles'; 17269 17270 // Skip if already added 17271 if (document.getElementById(styleId)) { 17272 return; 17273 } 17274 17275 const style = document.createElement('style'); 17276 style.id = styleId; 17277 style.textContent = ` 17278 /* Loading indicator */ 17279 .gallery-loading { 17280 display: flex; 17281 flex-direction: column; 17282 justify-content: center; 17283 align-items: center; 17284 width: 100%; 17285 height: 100%; 17286 min-height: 200px; 17287 opacity: 0 !important; 17288 } 17289 17290 .gallery-loading .spinner { 17291 width: 50px; 17292 height: 50px; 17293 border: 3px solid rgba(0, 0, 0, 0.1); 17294 border-radius: 50%; 17295 border-top-color: #4682B4; 17296 animation: spin 1s ease-in-out infinite; 17297 margin-bottom: 20px; 17298 opacity: 0 !important; 17299 } 17300 17301 @keyframes spin { 17302 to { transform: rotate(360deg); } 17303 } 17304 17305 .gallery-loading .loading-text { 17306 font-family: Arial, sans-serif; 17307 font-size: 16px; 17308 color: #555; 17309 opacity: 0; 17310 } 17311 17312 /* Error message */ 17313 .gallery-error { 17314 display: flex; 17315 flex-direction: column; 17316 justify-content: center; 17317 align-items: center; 17318 width: 100%; 17319 height: 100%; 17320 min-height: 200px; 17321 padding: 30px; 17322 text-align: center; 17323 } 17324 17325 .gallery-error h3 { 17326 font-family: Arial, sans-serif; 17327 font-size: 24px; 17328 color:rgba(211, 47, 47, 0.25); 17329 margin-bottom: 20px; 17330 } 17331 17332 .gallery-error p { 17333 font-family: Arial, sans-serif; 17334 font-size: 16px; 17335 color: #555; 17336 max-width: 600px; 17337 margin-bottom: 10px; 17338 } 17339 17340 .gallery-error .error-details { 17341 font-size: 14px; 17342 color: #888; 17343 font-style: italic; 17344 } 17345 `; 17346 17347 document.head.appendChild(style); 17348 } 17349 17350 // Initialize gallery system immediately if possible 17351 initializeGallerySystem(); 17352})(); 17353 17354 17355/** 17356 * Enhanced Gallery Link Interceptor 17357 * Handles clicks on gallery links to prevent full page reloads 17358 */ 17359(function() { 17360 // Store any loaded gallery data for quick reference 17361 const GALLERY_DATA_CACHE = { 17362 initialized: false, 17363 galleries: [], 17364 lastUpdate: 0 17365 }; 17366 17367 // Initialize when DOM is ready to catch early clicks 17368 document.addEventListener('DOMContentLoaded', initGalleryLinkInterceptor); 17369 17370 // Also initialize when window loads to ensure complete gallery data 17371 window.addEventListener('load', function() { 17372 // Use a small delay to ensure galleries have fully initialized 17373 setTimeout(initGalleryLinkInterceptor, 200); 17374 }); 17375 17376 // Additional init on first user interaction 17377 document.addEventListener('click', function initOnFirstClick() { 17378 initGalleryLinkInterceptor(); 17379 // Remove this listener after first click 17380 document.removeEventListener('click', initOnFirstClick); 17381 }, { once: true }); 17382 17383 // Main initialization function 17384 function initGalleryLinkInterceptor() { 17385 // Prevent multiple initializations 17386 if (window._galleryLinkInterceptorInitialized) { 17387 // If already initialized, just refresh the gallery data 17388 refreshGalleryCache(); 17389 return; 17390 } 17391 17392 window._galleryLinkInterceptorInitialized = true; 17393 console.log('Initializing enhanced gallery link interceptor'); 17394 17395 // Update our cache with current galleries data 17396 refreshGalleryCache(); 17397 17398 // Get primary containers that might contain links 17399 const galleryContainer = document.querySelector('.gallery-container'); 17400 const mainContainer = document.querySelector('.container') || document.body; 17401 17402 if (!galleryContainer && !mainContainer) { 17403 console.warn('No gallery containers found, link interceptor partially initialized'); 17404 } 17405 17406 // Add global click handler to catch all link clicks 17407 document.addEventListener('click', handleGlobalLinkClick, true); 17408 17409 // Try to intercept links from iframes - critical for gallery content 17410 interceptAllIframeLinks(); 17411 17412 // Set up a mutation observer to handle dynamically added content 17413 setupMutationObserver(); 17414 17415 // Log initialization with site details 17416 const isPreviewMode = window.location.hostname === 'preview.neonsky.app'; 17417 console.log(`Gallery link interceptor initialized. Preview mode: ${isPreviewMode}`); 17418 17419 // Create global helper for debugging 17420 window.debugGalleryLinks = function() { 17421 return { 17422 inPreviewMode: isPreviewMode, 17423 interceptorInitialized: window._galleryLinkInterceptorInitialized, 17424 cachedGalleries: GALLERY_DATA_CACHE.galleries.length, 17425 urlPath: window.location.pathname, 17426 siteGuid: getSiteGuidFromPath() 17427 }; 17428 }; 17429 } 17430 17431 // Update the gallery cache when gallery data changes 17432function refreshGalleryCache() { 17433 // Initialize GALLERY_DATA_CACHE if it doesn't exist 17434 if (!window.GALLERY_DATA_CACHE) { 17435 window.GALLERY_DATA_CACHE = { 17436 initialized: false, 17437 galleries: [], 17438 lastUpdate: 0 17439 }; 17440 } 17441 17442 // Check for global galleries variable first 17443 if (typeof galleries !== 'undefined' && Array.isArray(galleries)) { 17444 // Use the global galleries array 17445 window.GALLERY_DATA_CACHE.galleries = galleries; 17446 window.GALLERY_DATA_CACHE.initialized = true; 17447 window.GALLERY_DATA_CACHE.lastUpdate = Date.now(); 17448 window.galleries = galleries; 17449 17450 console.log(`Gallery cache refreshed with ${galleries.length} galleries from global variable`); 17451 return; 17452 } 17453 17454 // Debug log 17455 console.log(`Gallery cache refreshed with ${GALLERY_DATA_CACHE.galleries.length} galleries`); 17456 } 17457 17458 // Global click handler - catches all link clicks 17459 function handleGlobalLinkClick(e) {
17460 // Find if a link was clicked 17461 const link = e.target.closest('a'); 17462 if (!link || !link.href) return; 17463 17464 // Skip if the link uses special targets or modifiers 17465 if (link.target === '_blank' || e.ctrlKey || e.metaKey || e.shiftKey) return; 17466 17467 // Skip for hash links or javascript: links 17468 if (link.href.includes('#') || link.href.startsWith('javascript:')) return; 17469 17470 try { 17471 // Parse the URL to work with it 17472 const url = new URL(link.href); 17473 17474 // Only intercept links to the same domain 17475 if (url.hostname !== window.location.hostname) return; 17476 17477 // Skip API and resource URLs 17478 if (url.pathname.includes('/api/') || isResourceUrl(url.pathname)) return; 17479 17480 console.log('Link intercepted:', url.pathname); 17481 17482 // For preview URLs, we need to handle the path specially 17483 const isPreviewMode = window.location.hostname === 'preview.neonsky.app'; 17484 const siteGuid = getSiteGuidFromPath(); 17485 17486 console.log('Link interceptor - isPreviewMode:', isPreviewMode, 'siteGuid:', siteGuid); 17487 17488 // Site Graph card links carry data-sg-target-id â use SPA navigation (not preview iframe fallback) 17489 if (link.classList.contains('sg-card-link') && link.closest('.site-graph-element') && link.dataset.sgTargetId) { 17490 let galleriesData = (typeof galleries !== 'undefined' && Array.isArray(galleries)) 17491 ? galleries 17492 : window.galleries; 17493 const sgItem = Array.isArray(galleriesData) 17494 ? findGalleryById(galleriesData, link.dataset.sgTargetId) 17495 : null; 17496 if (sgItem) { 17497 e.preventDefault(); 17498 e.stopPropagation(); 17499 if (sgItem.isPage && window.loadPage) { 17500 window.loadPage(sgItem.id); 17501 } else if (window.loadGallery) { 17502 window.loadGallery(sgItem.id); 17503 } 17504 let targetUrl = url.href; 17505 if (isPreviewMode && siteGuid) { 17506 const pathname = url.pathname.startsWith(`/${siteGuid}/`) ? 17507 url.pathname : 17508 `/${siteGuid}${url.pathname.startsWith('/') ? url.pathname : '/' + url.pathname}`; 17509 const newUrl = new URL(url); 17510 newUrl.pathname = pathname; 17511 targetUrl = newUrl.href; 17512 } 17513 window.history.pushState( 17514 { galleryId: sgItem.id }, 17515 sgItem.title || document.title, 17516 targetUrl 17517 ); 17518 document.title = formatGalleryDocumentTitle(sgItem.title); 17519 window.scrollTo(0, 0); 17520 return; 17521 } 17522 } 17523 17524 // Check if this is internal navigation first 17525 let matchingItem = findGalleryByPath(url.pathname, isPreviewMode, siteGuid); 17526 17527 console.log('Link interceptor - matchingItem found:', !!matchingItem, matchingItem ? matchingItem.title : 'none'); 17528 17529 if (matchingItem) { 17530 // Prevent default navigation (full page reload) 17531 e.preventDefault(); 17532 e.stopPropagation(); 17533 17534 console.log('Found matching gallery for link:', matchingItem.title); 17535 17536 const nestedLinkModeActive = typeof window.isNestedGalleryLinkModeEnabled === 'function' 17537 ? window.isNestedGalleryLinkModeEnabled() 17538 : !!(window.neonGalleryConfig?.useLinks && window.neonGalleryConfig?.openMultipleLightboxes); 17539 const isGridGalleryLink = !!(link.closest('.masonry-item') || 17540 (link.closest('.gallery-wrapper, #neon-gallery-container, .gallery-direct-content, .masonry') && 17541 link.querySelector('img') && 17542 !link.closest('#neon-lightbox, .neon-lightbox'))); 17543 17544 if (!matchingItem.isPage && 17545 nestedLinkModeActive && 17546 isGridGalleryLink && 17547 typeof window.openNestedGalleryLightbox === 'function') { 17548 void window.openNestedGalleryLightbox(matchingItem, window.activeGalleryId).then((opened) => { 17549 if (!opened) { 17550 console.warn('Nested gallery lightbox failed â staying on parent gallery (no loadGallery fallback)'); 17551 } 17552 }); 17553 return; 17554 } 17555 17556 // Use the existing navigation functions 17557 if (matchingItem.isPage && window.loadPage) { 17558 window.loadPage(matchingItem.id); 17559 } else if (window.loadGallery) { 17560 window.loadGallery(matchingItem.id); 17561 } 17562 17563 // Construct proper URL for preview mode 17564 let targetUrl = url.href; 17565 if (isPreviewMode && siteGuid) { 17566 // Ensure we keep the GUID in the URL path 17567 const pathname = url.pathname.startsWith(`/${siteGuid}/`) ? 17568 url.pathname : 17569 `/${siteGuid}${url.pathname.startsWith('/') ? url.pathname : '/' + url.pathname}`; 17570 17571 const newUrl = new URL(url); 17572 newUrl.pathname = pathname; 17573 targetUrl = newUrl.href; 17574 } 17575 17576 // Update URL without reload 17577 window.history.pushState( 17578 { galleryId: matchingItem.id }, 17579 matchingItem.title || document.title, 17580 targetUrl 17581 ); 17582 17583 // Update page title 17584 document.title = formatGalleryDocumentTitle(matchingItem.title); 17585 17586 // Scroll to top 17587 window.scrollTo(0, 0); 17588 17589 return; 17590 } 17591 // Special case for preview mode - even if no matching gallery found 17592 else if (isPreviewMode && siteGuid) { 17593 // We're in preview mode with a GUID - intercept the navigation 17594 // to maintain the GUID in the URL. Only do this when #galleryFrame 17595 // can load content; otherwise allow SPA onclick handlers to run. 17596 17597 console.log('No direct match but in preview mode - handling navigation'); 17598 17599 const frame = document.getElementById('galleryFrame'); 17600 17601 if (!frame) { 17602 console.warn('Gallery frame not found - allowing default/SPA handlers'); 17603 } else { 17604 // Prevent default navigation 17605 e.preventDefault(); 17606 e.stopPropagation(); 17607 17608 // Construct a URL that preserves the GUID 17609 let newPathname = url.pathname; 17610 if (!newPathname.startsWith(`/${siteGuid}/`)) { 17611 // Add the GUID to the path if not already there 17612 newPathname = `/${siteGuid}${newPathname.startsWith('/') ? newPathname : '/' + newPathname}`; 17613 } 17614 17615 // Create the preview-friendly URL for browser history 17616 const browserUrl = new URL(window.location.origin); 17617 browserUrl.pathname = newPathname; 17618 17619 console.log('Constructing preview URL with GUID:', browserUrl.href); 17620 17621 // IMPORTANT: For gallery navigation, we need to create a special URL 17622 // that tells the gallery system to load the content directly 17623 console.log('Loading preview path in iframe:', newPathname); 17624 17625 // First update browser URL to show the correct path 17626 window.history.pushState(
17627 { customPath: newPathname }, 17628 document.title, 17629 browserUrl.href 17630 ); 17631 17632 // Create direct path to the gallery content 17633 const cleanPath = newPathname.replace(`/${siteGuid}/`, '/'); 17634 17635 // For direct gallery paths, we need to load the gallery with special parameters 17636 // This tells the gallery system to load content from this path 17637 const galleryUrl = `https://storage.neonsky.app/sites:site_${siteGuid}.json`; 17638 17639 // Two approaches to loading the content: 17640 17641 // 1. Use the gallery wrapper approach 17642 const wrapperUrl = `https://storage.neonsky.app/standalone/gallery-wrapper.html` + 17643 `?siteId=${siteGuid}` + 17644 `&path=${encodeURIComponent(cleanPath)}` + 17645 `&directPath=true` + 17646 `&t=${Date.now()}`; // Cache buster 17647 17648 console.log('Loading gallery via wrapper URL:', wrapperUrl); 17649 frame.src = wrapperUrl; 17650 17651 // Clear any active gallery ID since we're navigating directly 17652 if (window.activeGalleryId) { 17653 console.log('Clearing active gallery ID during direct navigation'); 17654 window.activeGalleryId = null; 17655 } 17656 17657 return; // Critical: prevent further navigation 17658 } 17659 } 17660 17661 console.log('No matching gallery or special handling - allow normal navigation'); 17662 } catch (error) { 17663 console.error('Error processing link click:', error); 17664 } 17665 } 17666 17667 // Handle all iframes on the page 17668 function interceptAllIframeLinks() { 17669 // Look for any iframe that might contain gallery content 17670 const iframes = document.querySelectorAll('iframe'); 17671 17672 iframes.forEach(iframe => { 17673 if (iframe.id === 'galleryFrame' || iframe.classList.contains('gallery-iframe')) { 17674 // Primary gallery iframe - high priority 17675 setupIframeIntercept(iframe, true); 17676 } else { 17677 // Any other iframe - still try to intercept 17678 setupIframeIntercept(iframe, false); 17679 } 17680 }); 17681 17682 console.log(`Attempted to intercept links in ${iframes.length} iframes`); 17683 } 17684 17685 // Setup link interception for a specific iframe 17686 function setupIframeIntercept(iframe, isPrimary) { 17687 try { 17688 // Skip if iframe is not fully loaded yet 17689 if (!iframe.contentWindow || !iframe.contentDocument) { 17690 // Add load event to try again when loaded 17691 iframe.addEventListener('load', function() { 17692 setupIframeIntercept(iframe, isPrimary); 17693 }); 17694 return; 17695 } 17696 17697 // Try to access the iframe content - might fail due to same-origin policy 17698 try { 17699 const iframeDoc = iframe.contentDocument; 17700 const iframeWin = iframe.contentWindow; 17701 17702 // Add event listener for all link clicks in the iframe 17703 iframeDoc.addEventListener('click', function(e) { 17704 const link = e.target.closest('a'); 17705 if (!link || !link.href) return; 17706 17707 // Skip special cases 17708 if (link.target === '_blank' || e.ctrlKey || e.metaKey || e.shiftKey) return; 17709 if (link.href.includes('#') || link.href.startsWith('javascript:')) return; 17710 17711 try { 17712 const url = new URL(link.href); 17713 17714 // Check if same domain or relative URL 17715 const isSameDomain = url.hostname === window.location.hostname || 17716 url.hostname === iframe.src.hostname || 17717 !url.hostname; 17718 17719 if (isSameDomain) { 17720 const isPreviewMode = window.location.hostname === 'preview.neonsky.app'; 17721 const siteGuid = getSiteGuidFromPath(); 17722 17723 // First try to find a matching gallery/page 17724 const matchingItem = parent.findGalleryByPath ? 17725 parent.findGalleryByPath(url.pathname, isPreviewMode, siteGuid) : 17726 findGalleryByPath(url.pathname, isPreviewMode, siteGuid); 17727 17728 if (matchingItem) { 17729 // We found a match - prevent default and navigate 17730 e.preventDefault(); 17731 e.stopPropagation(); 17732 17733 console.log('Iframe: Found matching content for link:', matchingItem.title); 17734 17735 // Access parent window functions 17736 if (matchingItem.isPage && parent.loadPage) { 17737 parent.loadPage(matchingItem.id); 17738 } else if (parent.loadGallery) { 17739 parent.loadGallery(matchingItem.id); 17740 } 17741 17742 // Update URL in parent window 17743 let targetUrl = url.href; 17744 if (isPreviewMode && siteGuid) { 17745 // Ensure GUID is preserved in URL 17746 const pathname = url.pathname.startsWith(`/${siteGuid}/`) ? 17747 url.pathname : 17748 `/${siteGuid}${url.pathname.startsWith('/') ? url.pathname : '/' + url.pathname}`; 17749 17750 const newUrl = new URL(window.location.origin + pathname); 17751 targetUrl = newUrl.href; 17752 } 17753 17754 // Update browser URL 17755 parent.history.pushState( 17756 { galleryId: matchingItem.id }, 17757 matchingItem.title || document.title, 17758 targetUrl 17759 ); 17760 17761 // Update parent window title 17762 parent.document.title = formatGalleryDocumentTitle(matchingItem.title, parent); 17763 17764 // Scroll parent to top 17765 parent.scrollTo(0, 0); 17766 return; 17767 } 17768 // Special handling for preview mode - critical fix here! 17769 else if (isPreviewMode && siteGuid) { 17770 // Special handling for preview URLs 17771 e.preventDefault(); 17772 e.stopPropagation(); 17773 17774 console.log('Iframe: No direct match but in preview mode - handling navigation'); 17775 17776 // Construct a URL that preserves the GUID 17777 let newPathname = url.pathname; 17778 if (!newPathname.startsWith(`/${siteGuid}/`)) { 17779 newPathname = `/${siteGuid}${newPathname.startsWith('/') ? newPathname : '/' + newPathname}`; 17780 } 17781 17782 // Create a URL for the browser history 17783 const browserUrl = new URL(window.location.origin); 17784 browserUrl.pathname = newPathname; 17785 17786 console.log('Iframe: Constructing preview URL with GUID:', browserUrl.href); 17787 17788 // Update browser URL in parent 17789 parent.history.pushState(
17790 { customPath: newPathname }, 17791 document.title, 17792 browserUrl.href 17793 ); 17794 17795 // Handle the navigation in the parent window 17796 if (isPrimary && parent.document.getElementById('galleryFrame')) { 17797 const galleryFrame = parent.document.getElementById('galleryFrame'); 17798 17799 // Create clean path for the gallery system 17800 const cleanPath = newPathname.replace(`/${siteGuid}/`, '/'); 17801 17802 // Create wrapper URL for gallery content 17803 const wrapperUrl = `https://storage.neonsky.app/standalone/gallery-wrapper.html` + 17804 `?siteId=${siteGuid}` + 17805 `&path=${encodeURIComponent(cleanPath)}` + 17806 `&directPath=true` + 17807 `&t=${Date.now()}`; // Cache buster 17808 17809 console.log('Iframe: Loading gallery via wrapper URL:', wrapperUrl); 17810 galleryFrame.src = wrapperUrl; 17811 17812 // Clear any active gallery ID since we're navigating directly 17813 if (parent.activeGalleryId) { 17814 console.log('Iframe: Clearing active gallery ID during direct navigation'); 17815 parent.activeGalleryId = null; 17816 } 17817 } else { 17818 // Fallback - navigate normally in case we can't handle it 17819 console.log('Iframe: Gallery frame not found or not primary, navigating normally'); 17820 window.location.href = browserUrl.href; 17821 } 17822 return; 17823 } 17824 } 17825 } catch (iframeError) { 17826 console.error('Error handling iframe link:', iframeError); 17827 } 17828 }); 17829 17830 if (isPrimary) { 17831 console.log('Successfully intercepted links in primary gallery iframe'); 17832 } 17833 } catch (sameOriginError) { 17834 // This is expected for cross-origin iframes 17835 if (isPrimary) { 17836 console.warn('Cannot access iframe content due to same-origin policy. Using fallback method.'); 17837 17838 // Apply a fallback method - add postMessage listener 17839 setupPostMessageListener(iframe); 17840 } 17841 } 17842 } catch (error) { 17843 console.warn('Error setting up iframe link interception:', error); 17844 } 17845 } 17846 17847 // Fallback: use postMessage for cross-origin iframes 17848 function setupPostMessageListener(iframe) { 17849 // Listen for messages from the iframe 17850 window.addEventListener('message', function(event) { 17851 try { 17852 // Verify the message is from our iframe 17853 if (event.source !== iframe.contentWindow) return; 17854 17855 const data = event.data; 17856 17857 // Check if this is a link click event 17858 if (data && data.type === 'linkClick' && data.href) { 17859 console.log('Received link click message from iframe:', data.href); 17860 17861 // Create a URL object to work with the link 17862 const url = new URL(data.href); 17863 17864 // Check if it's a local navigation 17865 if (url.hostname === window.location.hostname) { 17866 const isPreviewMode = window.location.hostname === 'preview.neonsky.app'; 17867 const siteGuid = getSiteGuidFromPath(); 17868 17869 // Try to find a matching gallery 17870 const matchingItem = findGalleryByPath(url.pathname, isPreviewMode, siteGuid); 17871 17872 if (matchingItem) { 17873 console.log('Found matching gallery for postMessage link:', matchingItem.title); 17874 17875 // Navigate using the appropriate function 17876 if (matchingItem.isPage && window.loadPage) { 17877 window.loadPage(matchingItem.id); 17878 } else if (window.loadGallery) { 17879 window.loadGallery(matchingItem.id); 17880 } 17881 17882 // Update URL without reload 17883 let targetUrl = url.href; 17884 if (isPreviewMode && siteGuid) { 17885 // Ensure GUID is preserved 17886 const pathname = url.pathname.startsWith(`/${siteGuid}/`) ? 17887 url.pathname : 17888 `/${siteGuid}${url.pathname.startsWith('/') ? url.pathname : '/' + url.pathname}`; 17889 17890 const newUrl = new URL(window.location.origin + pathname); 17891 targetUrl = newUrl.href; 17892 } 17893 17894 window.history.pushState( 17895 { galleryId: matchingItem.id }, 17896 matchingItem.title || document.title, 17897 targetUrl 17898 ); 17899 17900 // Update page title 17901 document.title = formatGalleryDocumentTitle(matchingItem.title); 17902 17903 return; 17904 } 17905 // Special handling for preview mode - Fixed! 17906 else if (isPreviewMode && siteGuid) { 17907 console.log('No direct match but in preview mode - handling navigation via postMessage'); 17908 17909 // Construct URL that preserves the GUID 17910 let newPathname = url.pathname; 17911 if (!newPathname.startsWith(`/${siteGuid}/`)) { 17912 newPathname = `/${siteGuid}${newPathname.startsWith('/') ? newPathname : '/' + newPathname}`; 17913 } 17914 17915 // Create browser URL 17916 const browserUrl = new URL(window.location.origin); 17917 browserUrl.pathname = newPathname; 17918 17919 // Update browser URL 17920 window.history.pushState(
17921 { customPath: newPathname }, 17922 document.title, 17923 browserUrl.href 17924 ); 17925 17926 // Get the main gallery frame - NOT the iframe that sent the message 17927 const galleryFrame = document.getElementById('galleryFrame'); 17928 if (galleryFrame) { 17929 // Create clean path without GUID 17930 const cleanPath = newPathname.replace(`/${siteGuid}/`, '/'); 17931 17932 // Create wrapper URL 17933 const wrapperUrl = `https://storage.neonsky.app/standalone/gallery-wrapper.html` + 17934 `?siteId=${siteGuid}` + 17935 `&path=${encodeURIComponent(cleanPath)}` + 17936 `&directPath=true` + 17937 `&t=${Date.now()}`; // Cache buster 17938 17939 console.log('Loading gallery via wrapper URL (postMessage):', wrapperUrl); 17940 galleryFrame.src = wrapperUrl; 17941 17942 // Clear any active gallery ID 17943 if (window.activeGalleryId) { 17944 window.activeGalleryId = null; 17945 } 17946 17947 return; 17948 } else { 17949 console.warn('Gallery frame not found - cannot load content via postMessage'); 17950 } 17951 } 17952 } 17953 } 17954 } catch (error) { 17955 console.error('Error processing postMessage from iframe:', error); 17956 } 17957 }); 17958 17959 // Inject a script into the iframe if possible to send messages back 17960 try { 17961 // We might be able to inject a script tag even in cross-origin scenarios 17962 // if the iframe is not yet loaded or using document.write 17963 iframe.addEventListener('load', function() { 17964 try { 17965 const script = iframe.contentDocument.createElement('script'); 17966 script.textContent = ` 17967 // Link interception script injected from parent 17968 document.addEventListener('click', function(e) { 17969 const link = e.target.closest('a'); 17970 if (link && link.href && !link.target && !e.ctrlKey && !e.metaKey && !e.shiftKey) { 17971 // Send message to parent window 17972 window.parent.postMessage({ 17973 type: 'linkClick', 17974 href: link.href, 17975 pathname: link.pathname 17976 }, '*'); 17977 17978 // Prevent default navigation 17979 e.preventDefault(); 17980 } 17981 }); 17982 console.log('Gallery link interceptor injected from parent'); 17983 `; 17984 iframe.contentDocument.head.appendChild(script); 17985 } catch (e) { 17986 // This will likely fail for cross-origin iframes 17987 console.log('Could not inject script into iframe - expected for cross-origin frames'); 17988 } 17989 }); 17990 } catch (error) { 17991 console.warn('Error trying to inject script into iframe:', error); 17992 } 17993 } 17994 17995 // Observer for new content/iframes 17996 function setupMutationObserver() { 17997 if (!window.MutationObserver) return; 17998 17999 const observer = new MutationObserver(function(mutations) { 18000 let newIframesFound = false; 18001 18002 mutations.forEach(function(mutation) { 18003 if (mutation.type === 'childList' && mutation.addedNodes.length) { 18004 // Check if any new iframes were added 18005 mutation.addedNodes.forEach(function(node) { 18006 // Check if the node is an iframe or contains iframes 18007 if (node.nodeName === 'IFRAME') { 18008 newIframesFound = true; 18009 setupIframeIntercept(node, node.id === 'galleryFrame'); 18010 } else if (node.querySelectorAll) { 18011 const iframes = node.querySelectorAll('iframe'); 18012 if (iframes.length > 0) { 18013 newIframesFound = true; 18014 iframes.forEach(function(iframe) { 18015 setupIframeIntercept(iframe, iframe.id === 'galleryFrame'); 18016 }); 18017 } 18018 } 18019 }); 18020 } 18021 }); 18022 18023 // If new iframes were found, update our cache 18024 if (newIframesFound) { 18025 refreshGalleryCache(); 18026 } 18027 }); 18028 18029 // Observe the entire document 18030 observer.observe(document.body, { 18031 childList: true, 18032 subtree: true 18033 }); 18034 } 18035 18036 // Helper to check if a URL is a resource rather than a page 18037 function isResourceUrl(path) { 18038 const resourceExtensions = [ 18039 '.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', 18040 '.css', '.js', '.pdf', '.ico', '.ttf', '.woff', '.woff2' 18041 ]; 18042 return resourceExtensions.some(ext => path.toLowerCase().endsWith(ext)); 18043 } 18044 18045 // Get the site GUID from the path in preview mode 18046 function getSiteGuidFromPath() { 18047 if (window.location.hostname === 'preview.neonsky.app') { 18048 const pathParts = window.location.pathname.split('/').filter(
18048Boolean); 18049 if (pathParts.length > 0) { 18050 return pathParts[0]; 18051 } 18052 } 18053 return null; 18054 } 18055 18056 18057/** 18058 * Enhanced gallery path matching with improved accuracy: 18059 * 18060 * This function finds the appropriate gallery based on URL path with better 18061 * prioritization and more precise matching criteria. 18062 * 18063 * @param {string} path - The URL path to match 18064 * @param {boolean} isPreviewMode - Whether we're in preview mode 18065 * @param {string} siteGuid - The site GUID (for preview mode) 18066 * @returns {Object|null} The matching gallery or null if none found 18067 */ 18068function findGalleryByPath(path, isPreviewMode, siteGuid) { 18069 // Ensure path is a string 18070 if (typeof path !== 'string') { 18071 console.warn('Invalid path provided to findGalleryByPath:', path); 18072 return null; 18073 } 18074 18075 // Determine which galleries array to use - check global variable first 18076 let galleriesData; 18077 let galleryCount = 0; 18078 18079 if (typeof galleries !== 'undefined' && Array.isArray(galleries)) { 18080 galleriesData = galleries; 18081 galleryCount = galleries.length; 18082 } else if (typeof window.galleries !== 'undefined' && Array.isArray(window.galleries)) { 18083 galleriesData = window.galleries; 18084 galleryCount = window.galleries.length; 18085 } else if (window.GALLERY_DATA_CACHE && 18086 window.GALLERY_DATA_CACHE.initialized && 18087 Array.isArray(window.GALLERY_DATA_CACHE.galleries)) { 18088 galleriesData = window.GALLERY_DATA_CACHE.galleries; 18089 galleryCount = window.GALLERY_DATA_CACHE.galleries.length; 18090 } else { 18091 console.warn('No galleries array available'); 18092 return null; 18093 } 18094 18095 console.log(`Searching for match among ${galleryCount} galleries`); 18096 18097 // Strip leading/trailing slashes without regex â template-literal embedding mangles / escapes 18098 // (e.g. /^/|/$/g becomes /^/|/$/g â 0), which left "/" unnormalized and broke home matching. 18099 function stripEdgeSlashes(value) { 18100 return String(value || '').split('/').filter(Boolean).join('/'); 18101 } 18102 18103 // Normalize the path for comparison (handle trailing/leading slashes) 18104 let normalizedPath = stripEdgeSlashes(path).toLowerCase(); 18105 const originalNormalizedPath = normalizedPath; // Keep original for logging 18106 18107 // If this is a preview URL, we need to handle the path differently 18108 if (isPreviewMode && siteGuid) { 18109 // Check if the path starts with the GUID 18110 if (normalizedPath.startsWith(siteGuid.toLowerCase() + '/')) { 18111 // Remove the GUID prefix 18112 normalizedPath = normalizedPath.substring(siteGuid.length + 1); 18113 } else if (normalizedPath === siteGuid.toLowerCase()) { 18114 // This is just the GUID with no additional path 18115 normalizedPath = ''; 18116 } 18117 } 18118 18119 // Create a dash-normalized version (all sequences of dashes become single dashes) 18120 // This handles both double-dash patterns and any other dash inconsistencies 18121 const normalizedPathWithSingleDashes = normalizedPath.replace(/-+/g, '-'); 18122 18123 console.log(`Normalized path: "${normalizedPath}" (dash-normalized: "${normalizedPathWithSingleDashes}")`); 18124 18125 // For empty paths, try to find the homepage 18126 if (!normalizedPath) { 18127 // Look for a gallery marked as home page 18128 const homePage = galleriesData.find(g => g.isHomePage === true); 18129 if (homePage) { 18130 console.log('Empty path - returning homepage:', homePage.title); 18131 return homePage; 18132 } 18133 } 18134 18135 // Array to store potential matches with their confidence levels 18136 const potentialMatches = []; 18137 18138 // Process each gallery to find potential matches with confidence levels 18139 galleriesData.forEach(gallery => { 18140 // Skip invalid galleries 18141 if (!gallery || !gallery.id) return; 18142 18143 // Store matching details for debugging 18144 const matchInfo = { 18145 gallery, 18146 confidence: 0, 18147 matchType: null, 18148 usedNormalizedDashes: false 18149 }; 18150 18151 // 1. EXACT SLUG MATCH (highest confidence) 18152 if (gallery.slug) { 18153 const gallerySlug = stripEdgeSlashes(gallery.slug).toLowerCase(); 18154 18155 // Try original path 18156 if (gallerySlug === normalizedPath) { 18157 matchInfo.confidence = 100; // Max confidence 18158 matchInfo.matchType = 'exact_slug'; 18159 potentialMatches.push({...matchInfo}); 18160 return; // Continue to next gallery 18161 } 18162 18163 // Try dash-normalized path 18164 const gallerySlugWithSingleDashes = gallerySlug.replace(/-+/g, '-'); 18165 if (normalizedPathWithSingleDashes === gallerySlugWithSingleDashes) { 18166 matchInfo.confidence = 95; // Very high confidence 18167 matchInfo.matchType = 'exact_slug_normalized_dashes'; 18168 matchInfo.usedNormalizedDashes = true; 18169 potentialMatches.push({...matchInfo}); 18170 return; // Continue to next gallery 18171 } 18172 } 18173 18174 // 2. EXACT URL PATH MATCH (very high confidence) 18175 if (gallery.url) { 18176 let galleryPath = gallery.url.toLowerCase(); 18177 18178 if (galleryPath.startsWith('http')) { 18179 try { 18180 const urlObj = new URL(galleryPath); 18181 galleryPath = urlObj.pathname; 18182 } catch (e) { 18183 // If URL parsing fails, use the string as is 18184 } 18185 } 18186 18187 galleryPath = stripEdgeSlashes(galleryPath); 18188 18189 // Try original path 18190 if (galleryPath === normalizedPath) { 18191 matchInfo.confidence = 90; // Very high confidence 18192 matchInfo.matchType = 'exact_url_path'; 18193 potentialMatches.push({...matchInfo}); 18194 return; // Continue to next gallery 18195 } 18196 18197 // Try dash-normalized path 18198 const galleryPathWithSingleDashes = galleryPath.replace(/-+/g, '-'); 18199 if (normalizedPathWithSingleDashes === galleryPathWithSingleDashes) { 18200 matchInfo.confidence = 85; // High confidence 18201 matchInfo.matchType = 'exact_url_path_normalized_dashes'; 18202 matchInfo.usedNormalizedDashes = true; 18203 potentialMatches.push({...matchInfo}); 18204 return; // Continue to next gallery 18205 } 18206 } 18207 18208 // 3. TITLE SLUG MATCH (high confidence) 18209 if (gallery.title) { 18210 const titleSlug = gallery.title.toLowerCase() 18211 .replace(/s+/g, '-') 18212 .replace(/[^w-]+/g, '') 18213 .replace(/--+/g, '-') 18214 .trim(); 18215 18216 // Try original path 18217 if (titleSlug === normalizedPath) { 18218 matchInfo.confidence = 80; // High confidence 18219 matchInfo.matchType = 'title_slug_match'; 18220 potentialMatches.push({...matchInfo}); 18221 return; // Continue to next gallery 18222 } 18223 18224 // Try dash-normalized path 18225 const titleSlugWithSingleDashes = titleSlug.replace(/-+/g, '-'); 18226 if (normalizedPathWithSingleDashes === titleSlugWithSingleDashes) { 18227 matchInfo.confidence = 75; // Good confidence 18228 matchInfo.matchType = 'title_slug_match_normalized_dashes'; 18229 matchInfo.usedNormalizedDashes = true; 18230 potentialMatches.push({...matchInfo}); 18231 return; // Continue to next gallery 18232 } 18233 } 18234 18235 // 4. PARTIAL SLUG MATCH - BUT WITH MUCH STRICTER CRITERIA 18236 if (gallery.slug && normalizedPath.length > 5) { 18237 const gallerySlug = stripEdgeSlashes(gallery.slug).toLowerCase(); 18238 const gallerySlugWithSingleDashes = gallerySlug.replace(/-+/g, '-'); 18239 18240 // Only if paths are substantial (to avoid false positives) 18241 if (normalizedPath.length >= 5 && gallerySlug.length >= 5) { 18242 // Try matching significant portions with dash normalization 18243 18244 // If the normalized paths contain each other with substantial overlap 18245 if (normalizedPathWithSingleDashes.includes(gallerySlugWithSingleDashes) || 18246 gallerySlugWithSingleDashes.includes(normalizedPathWithSingleDashes)) { 18247 18248 // Calculate overlap ratio based on the shorter string 18249 const overlapRatio = Math.min(normalizedPathWithSingleDashes.length, gallerySlugWithSingleDashes.length) / 18250 Math.max(normalizedPathWithSingleDashes.length, gallerySlugWithSingleDashes.length); 18251 18252 // Only consider matches with substantial overlap (over 70%) 18253 if (overlapRatio >= 0.7) { 18254 const score = Math.round(60 + (overlapRatio * 20)); // 60-80 based on overlap 18255 18256 matchInfo.confidence = score; 18257 matchInfo.matchType = 'partial_match_normalized'; 18258 matchInfo.usedNormalizedDashes = true; 18259 matchInfo.overlapRatio = overlapRatio; 18260 potentialMatches.push({...matchInfo}); 18261 } 18262 } 18263 } 18264 } 18265 }); 18266 18267 // No potential matches found 18268 if (potentialMatches.length === 0) { 18269 console.log('No gallery match found for path:', path); 18270 return null; 18271 } 18272 18273 // Sort potential matches by confidence (highest first) 18274 potentialMatches.sort((a, b) => b.confidence - a.confidence); 18275 18276 // Get the best match 18277 const bestMatch = potentialMatches[0]; 18278 18279 // Log detailed debugging info about the match 18280 console.log(`Best match for "${normalizedPath}":`, { 18281 title: bestMatch.gallery.title, 18282 id: bestMatch.gallery.id, 18283 matchType: bestMatch.matchType, 18284 confidence: bestMatch.confidence, 18285 usedNormalizedDashes: bestMatch.usedNormalizedDashes, 18286 totalMatches: potentialMatches.length 18287 }); 18288 18289 // If we have multiple close matches, log them for debugging 18290 if (potentialMatches.length > 1 && 18291 potentialMatches[1].confidence >= bestMatch.confidence - 10) { 18292 console.log('Close alternative matches:', potentialMatches.slice(1, 3).map(m => ({ 18293 title: m.gallery.title, 18294 id: m.gallery.id, 18295 matchType: m.matchType, 18296 confidence: m.confidence 18297 }
18297))); 18298 } 18299 18300 // Return the best matching gallery 18301 return bestMatch.gallery; 18302} 18303 18304 // Make helpful functions available globally 18305 window.findGalleryByPath = findGalleryByPath; 18306 window.findGalleryById = findGalleryById; 18307 window.refreshGalleryCache = refreshGalleryCache; 18308 window.getSiteGuidFromPath = getSiteGuidFromPath; 18309 if (typeof getSiteGalleriesArray === 'function') { 18310 window.getSiteGalleriesArray = getSiteGalleriesArray; 18311 } 18312})(); 18313 18314 18315 18316 </script>
18316 18317 </div> 18318 18319<!-- Sidebar element form for adding new elements --> 18320<div class="sidebar-element-form pe-edit-shell" id="sidebarElementForm" style="display: none;" data-nosnippet> 18321<p class="pe-edit-shell__title">Add sidebar element</p> 18322<p class="pe-hint">Select the type of element to add:</p> 18323 18324<div class="radio-group"> 18325 <label> 18326 <input type="radio" name="sidebarElementType" value="text" checked> Text Block 18327 </label> 18328 <label> 18329 <input type="radio" name="sidebarElementType" value="image"> Image 18330 </label> 18331 <label> 18332 <input type="radio" name="sidebarElementType" value="social"> Social Icons 18333 </label> 18334 <label> 18335 <input type="radio" name="sidebarElementType" value="spacer"> Spacer 18336 </label> 18337</div> 18338 18339<div class="pe-footer-actions"> 18340<button type="button" class="pe-btn pe-btn--primary" onclick="addSidebarElement()">Add element</button> 18341<button type="button" class="pe-btn" onclick="toggleSidebarElementForm()">Cancel</button> 18342</div> 18343</div> 18344 18345 <div class="add-form pe-edit-shell" id="addForm" data-nosnippet> 18346 <p class="pe-edit-shell__title">Add to menu</p> 18347 <input type="text" id="galleryTitle" placeholder="Gallery Title"> 18348 <div class="radio-group"> 18349 <label> 18350 <input type="radio" name="galleryType" value="integrated" checked> Gallery 18351 </label> 18352 <label> 18353 <input type="radio" name="galleryType" value="page"> Page 18354 </label> 18355 <label> 18356 <input type="radio" name="galleryType" value="folder"> Folder 18357 </label> 18358 <label> 18359 <input type="radio" name="galleryType" value="spacer"> Spacer 18360 </label> 18361 <label> 18362 <input type="radio" name="galleryType" value="external"> External URL 18363 </label> 18364</div> 18365 <div id="urlInputContainer"> 18366 <input type="text" id="galleryUrl" placeholder="Gallery URL"> 18367 </div> 18368 <div id="galleryParentContainer" style="display: none;" aria-hidden="true"> 18369 <label for="galleryParent">Parent Menu:</label> 18370 <select id="galleryParent"> 18371 <option value="">None (Top Level)</option> 18372 </select> 18373 </div> 18374 <div class="pe-footer-actions"> 18375 <button type="button" class="pe-btn pe-btn--primary" onclick="addGallery()">Save</button> 18376 </div> 18377
18377<script> 18378 18379 18380 // Add event listeners to the radio buttons 18381 document.addEventListener('DOMContentLoaded', () => { 18382 const radioButtons = document.querySelectorAll('input[name="galleryType"]'); 18383 radioButtons.forEach(radio => { 18384 radio.addEventListener('change', function() { 18385 const urlContainer = document.getElementById('urlInputContainer'); 18386 if (this.value === 'external') { 18387 urlContainer.style.display = 'block'; 18388 } else { 18389 urlContainer.style.display = 'none'; 18390 } 18391 }); 18392 }); 18393 }); 18394 </script>
18394 18395 </div> 18396 18397<div class="metadata-editor pe-edit-shell" id="metadataEditor" style="display: none; max-height: 90vh; overflow-y: auto;" data-nosnippet> 18398 <h3 class="pe-edit-shell__title">Site Metadata</h3> 18399 18400 <div class="form-group"> 18401 <label for="metaTitle">Page Title (Search Results & Browsers)</label> 18402 <input type="text" id="metaTitle" class="form-control" placeholder="Enter site title for search results"> 18403 <small>Recommended length: 50-60 characters</small> 18404 </div> 18405 18406 <div class="form-group"> 18407 <label for="metaDescription">Meta Description</label> 18408 <textarea id="metaDescription" class="form-control" rows="3" placeholder="Enter site description for search results"></textarea> 18409 <small>Recommended length: 150-160 characters</small> 18410 </div> 18411 18412 <div class="form-group"> 18413 <label for="ogImageUrl">Social preview image (Open Graph / X / iMessage)</label> 18414 <input type="hidden" id="ogImageUrl" class="form-control" value=""> 18415 <button type="button" id="uploadOgImageBtn" class="pe-btn upload-image-btn"> 18416 <svg style="width: 16px; height: 16px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 18417 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path> 18418 <polyline points="17 8 12 3 7 8"></polyline> 18419 <line x1="12" y1="3" x2="12" y2="15"></line> 18420 </svg> 18421 <span id="uploadOgImageBtnText">Upload image</span> 18422 </button> 18423 <div id="ogImagePreviewContainer"></div> 18424 <small>Recommended about 1200Ã630px. Optional: when you upload an image here, it is used for link previews site-wide. If you leave this empty, the first image on each page or gallery (including the home page) is used instead.</small> 18425 </div> 18426 18427 <div class="form-group"> 18428 <label for="googleAnalytics">Google Analytics Code</label> 18429 <input type="text" id="googleAnalytics" class="form-control" placeholder="Enter GA-XXXXXXXXXX"> 18430 <small>Example: G-XXXXXXXXXX or UA-XXXXXXXX-X</small> 18431 </div> 18432 18433 <div class="form-group"> 18434 <label for="contentLanguage">Content Language</label> 18435 <select id="contentLanguage" class="form-control"> 18436 <option value="en">English (en)</option> 18437 <option value="es">Spanish / Español (es)</option> 18438 <option value="fr">French / Français (fr)</option> 18439 <option value="ne">Nepali / नà¥à¤ªà¤¾à¤²à¥ (ne)</option> 18440 <option value="de">German / Deutsch (de)</option> 18441 <option value="it">Italian / Italiano (it)</option> 18442 <option value="pt">Portuguese / Português (pt)</option> 18443 <option value="ru">Russian / Ð ÑÑÑкий (ru)</option> 18444 <option value="zh">Chinese / 䏿 (zh)</option> 18445 <option value="ja">Japanese / æ¥æ¬èª (ja)</option> 18446 <option value="ko">Korean / íêµì´ (ko)</option> 18447 <option value="ar">Arabic / Ø§ÙØ¹Ø±Ø¨ÙØ© (ar)</option> 18448 <option value="hi">Hindi / हिनà¥à¤¦à¥ (hi)</option> 18449 </select> 18450 <small>Set the primary language of your site content. Chrome will offer to translate if visitor's language is different.</small> 18451 </div> 18452 18453 <div class="form-group"> 18454 <label> 18455 <input type="checkbox" id="noIndexToggle"> 18456 Hide site from search engines (noindex) 18457 </label> 18458 <small>Use this if the site is under development or shouldn't appear in search results</small> 18459 </div> 18460 18461 <div class="form-group" style="border-top: 1px solid #ddd; padding-top: 16px; margin-top: 16px;"> 18462 <h4 style="margin-bottom: 8px;">Schema / Identity</h4> 18463 <p style="font-size: 13px; color: #555; margin-bottom: 12px;">Helps Google understand what this site represents â used in Knowledge Panels and local search results.</p> 18464 <label style="display: block; margin-bottom: 6px;">This site represents:</label> 18465 <div style="display: flex; gap: 20px; margin-bottom: 4px;"> 18466 <label style="display: flex; align-items: center; gap: 6px; font-weight: normal;"> 18467 <input type="radio" name="schemaType" id="schemaTypePerson" value="Person" onchange="updateSchemaFields()"> Person / Individual 18468 </label> 18469 <label style="display: flex; align-items: center; gap: 6px; font-weight: normal;"> 18470 <input type="radio" name="schemaType" id="schemaTypeOrganization" value="Organization" onchange="updateSchemaFields()"> Organization / Business 18471 </label> 18472 </div> 18473 <small>Select "Person" for individual photographers, artists, consultants. Select "Organization" for studios, agencies, companies.</small> 18474 </div> 18475 18476 <div class="form-group" id="schemaJobTitleGroup" style="display: none;"> 18477 <label for="schemaJobTitle">Job Title</label> 18478 <input type="text" id="schemaJobTitle" class="form-control" placeholder=""> 18479 <small>Used in Google Knowledge Panels and local search (e.g. "photojournalist Minneapolis").</small> 18480 </div> 18481 18482 <div class="form-group" id="schemaCityGroup" style="display: none;"> 18483 <label for="schemaCity">City / Location</label> 18484 <input type="text" id="schemaCity" class="form-control" placeholder=""> 18485 <small>Improves visibility in location-based searches.</small> 18486 </div> 18487 18488 <div class="form-group" id="schemaEmailGroup" style="display: none;"> 18489 <label for="schemaEmail">Contact Email (for structured data)</label> 18490 <input type="text" id="schemaEmail" class="form-control" placeholder="e.g. [email protected]"> 18491 <small>Embedded in structured data only â not displayed on the page.</small> 18492 </div> 18493 18494 <div class="form-group" style="border-top: 1px solid #ddd; padding-top: 16px; margin-top: 16px;"> 18495 <h4 style="margin-bottom: 8px;">
18495Image licensing (optional)</h4> 18496 <p style="font-size: 13px; color: #555; margin-bottom: 12px;">Adds license URLs to gallery ImageObject structured data for Google Images. Leave both blank to keep current behavior. No badge is shown on your site.</p> 18497 <label for="imageLicenseUrl">License page URL</label> 18498 <input type="url" id="imageLicenseUrl" class="form-control" placeholder="https://example.com/licensing"> 18499 <small>A public page that states how images may be used (your terms, or a Creative Commons license).</small> 18500 </div> 18501 18502 <div class="form-group"> 18503 <label for="acquireLicensePageUrl">Acquire license page URL</label> 18504 <input type="url" id="acquireLicensePageUrl" class="form-control" placeholder="https://example.com/contact"> 18505 <small>A public page where someone can request or buy a license. Both URLs are required, or leave both empty.</small> 18506 </div> 18507 18508 <div class="form-group"> 18509 <label> 18510 <input type="checkbox" id="metadataCopyrightEnabled"> 18511 Enable sidebar copyright footer 18512 </label> 18513 <small>When enabled, the copyright text shows at the bottom of the sidebar.</small> 18514 </div> 18515 18516 <div class="form-group" id="metadataCopyrightTextGroup"> 18517 <label for="metadataCopyrightText">Copyright Text</label> 18518 <input type="text" id="metadataCopyrightText" class="form-control" placeholder="© 2025 Your Name"> 18519 <small>This text appears in the sidebar footer and inside the copyright overlay.</small> 18520 </div> 18521 18522 <div class="form-group"> 18523 <label for="faviconUrl">Favicon</label> 18524 <input type="hidden" id="faviconUrl" class="form-control"> 18525 <button type="button" id="uploadFaviconBtn" class="pe-btn upload-image-btn"> 18526 <svg style="width: 16px; height: 16px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 18527 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path> 18528 <polyline points="17 8 12 3 7 8"></polyline> 18529 <line x1="12" y1="3" x2="12" y2="15"></line> 18530 </svg> 18531 <span id="uploadFaviconBtnText">Upload Favicon</span> 18532 </button> 18533 <div id="faviconPreviewContainer"></div> 18534 <small>Upload an image file for your site favicon (recommended: 32x32px or 16x16px, PNG or ICO format)</small> 18535 </div> 18536 18537 <div class="form-group" id="customPublicSiteBypassGroup" hidden style="display: none; border-top: 1px solid #ddd; padding-top: 16px; margin-top: 16px;"> 18538 <h4 style="margin-bottom: 12px;">Custom HTML site (full bypass)</h4> 18539 <p style="font-size: 13px; color: #555; margin-bottom: 12px;"> 18540 Upload one self-contained HTML file (e.g. from an AI tool). When enabled, visitors to the site root see only that file â not the Hydra menu or gallery. 18541 To log in and edit, open <strong>/_manage</strong> on your domain, then press <strong>+</strong> (numpad + or Shift+= on US keyboards) to log in or toggle edit mode, then open Site Metadata from the edit bar. 18542 To log out in this browser, visit <strong>/_manage?logout=1</strong> or <strong>/_manage/logout</strong> (also works as <code>?hydra_logout=1</code> on any page). 18543 </p> 18544 <label> 18545 <input type="checkbox" id="customPublicSiteEnabled" /> 18546 Serve uploaded HTML as the public site (bypass Hydra shell on <code>/</code>) 18547 </label> 18548 <input type="hidden" id="customPublicSiteHtmlR2Key" value="" /> 18549 <input type="hidden" id="customPublicSiteHtmlFilenameStore" value="" /> 18550 <div class="form-group" style="margin-top: 10px;"> 18551 <button type="button" id="uploadCustomPublicHtmlBtn" class="pe-btn">Upload HTML file</button> 18552 <button type="button" id="removeCustomPublicHtmlBtn" class="pe-btn">Clear file</button> 18553 </div> 18554 <p id="customPublicSiteHtmlFilenameLabel" style="font-size: 13px; color: #666; margin-top: 8px;">No HTML file uploaded yet.</p> 18555 </div> 18556 18557 <div class="form-group" style="border-top: 1px solid #ddd; padding-top: 16px; margin-top: 16px;"> 18558 <h4 style="margin-bottom: 8px;">Site-wide password (browser login)</h4> 18559 <p style="font-size: 13px; color: #555; margin-bottom: 12px;"> 18560 When enabled, visitors must enter this username and password in the browserâs dialog before any page or asset loads (same credentials on your live domain and on 18561 <code>preview.neonsky.app/<your-site-id>/</code>). Editors still use Magic login to edit. Turn off to make the site fully public again. 18562 </p> 18563 <label> 18564 <input type="checkbox" id="siteWideBasicAuthEnabled" onchange="updateSiteWideBasicAuthFieldState()" /> 18565 Require HTTP Basic password for the entire site 18566 </label> 18567 <div id="siteWideBasicAuthFields" style="display: none; margin-top: 12px;"> 18568 <div class="form-group"> 18569 <label for="siteWideBasicAuthUsername">Username</label> 18570 <input type="text" id="siteWideBasicAuthUsername" class="form-control" autocomplete="username" /> 18571 </div> 18572 <div class="form-group"> 18573 <label for="siteWideBasicAuthPassword">Password</label> 18574 <input type="password" id="siteWideBasicAuthPassword" class="form-control" autocomplete="new-password" /> 18575 <small>Stored in your site configuration. Use a strong password; anyone with it can view the site.</small> 18576 </div> 18577 <div class="form-group"> 18578 <label for="siteWideBasicAuthRealm">Realm (optional)</label> 18579 <input type="text" id="siteWideBasicAuthRealm" class="form-control" placeholder="Site" /> 18580 <small>Label shown in the browser login dialog.</small> 18581 </div> 18582 </div> 18583 </div> 18584 18585 <div class="form-actions pe-footer-actions"> 18586 <button type="button" class="pe-btn pe-btn--primary" onclick="saveMetadata()">Save Metadata</button> 18587 <button type="button" class="pe-btn" onclick="toggleMetadataEditor()">Cancel</button> 18588 </div> 18589</div> 18590
18591<script> 18592var customPublicSiteOptionHeld = false; 18593var customPublicSiteOptionKeyBound = false; 18594 18595function isMetadataEditorOpen() { 18596 var editor = document.getElementById('metadataEditor'); 18597 if (!editor) return false; 18598 if (window.currentOpenForm === 'metadataEditor') return true; 18599 return editor.style.display === 'block'; 18600} 18601 18602function syncCustomPublicSiteBypassVisibility() { 18603 var group = document.getElementById('customPublicSiteBypassGroup'); 18604 if (!group) return; 18605 var show = isMetadataEditorOpen() && customPublicSiteOptionHeld; 18606 group.hidden = !show; 18607 group.style.display = show ? 'block' : 'none'; 18608} 18609 18610function bindCustomPublicSiteOptionKeyListeners() { 18611 if (customPublicSiteOptionKeyBound) return; 18612 customPublicSiteOptionKeyBound = true; 18613 18614 document.addEventListener('keydown', function(e) { 18615 if (!e.altKey) return; 18616 customPublicSiteOptionHeld = true; 18617 syncCustomPublicSiteBypassVisibility(); 18618 }, true); 18619 18620 document.addEventListener('keyup', function(e) { 18621 var isOptionRelease = 18622 e.key === 'Alt' || 18623 e.key === 'Option' || 18624 e.code === 'AltLeft' || 18625 e.code === 'AltRight'; 18626 if (!isOptionRelease) return; 18627 customPublicSiteOptionHeld = false; 18628 syncCustomPublicSiteBypassVisibility(); 18629 }, true); 18630} 18631 18632window.syncCustomPublicSiteBypassVisibility = syncCustomPublicSiteBypassVisibility; 18633window.bindCustomPublicSiteOptionKeyListeners = bindCustomPublicSiteOptionKeyListeners; 18634 18635function updateSiteWideBasicAuthFieldState() { 18636 const en = document.getElementById('siteWideBasicAuthEnabled'); 18637 const fields = document.getElementById('siteWideBasicAuthFields'); 18638 if (fields) fields.style.display = en && en.checked ? 'block' : 'none'; 18639} 18640 18641function updateSchemaFields() { 18642 const isPerson = document.getElementById('schemaTypePerson') && document.getElementById('schemaTypePerson').checked; 18643 const isOrg = document.getElementById('schemaTypeOrganization') && document.getElementById('schemaTypeOrganization').checked; 18644 const anySelected = isPerson || isOrg; 18645 const jobTitleGroup = document.getElementById('schemaJobTitleGroup'); 18646 const cityGroup = document.getElementById('schemaCityGroup'); 18647 const emailGroup = document.getElementById('schemaEmailGroup'); 18648 if (jobTitleGroup) jobTitleGroup.style.display = isPerson ? 'block' : 'none'; 18649 if (cityGroup) cityGroup.style.display = anySelected ? 'block' : 'none'; 18650 if (emailGroup) emailGroup.style.display = anySelected ? 'block' : 'none'; 18651} 18652 18653function uploadCustomPublicHtmlFile() { 18654 const siteId = window.Parameters && window.Parameters.siteId ? window.Parameters.siteId : (window.siteId || ''); 18655 if (!siteId) { 18656 alert('Site ID not found. Refresh the page and try again.'); 18657 return; 18658 } 18659 const authToken = window.ImageUploader && window.ImageUploader.getAuthToken ? window.ImageUploader.getAuthToken() : null; 18660 if (!authToken) { 18661 alert('Please log in first (press + on the page).'); 18662 return; 18663 } 18664 const input = document.createElement('input'); 18665 input.type = 'file'; 18666 input.accept = '.html,.htm,text/html'; 18667 input.addEventListener('change', function() { 18668 const file = input.files && input.files[0]; 18669 if (!file) return; 18670 const formData = new FormData(); 18671 formData.append('file', file); 18672 formData.append('guid', siteId); 18673 formData.append('purpose', 'custom-public-site'); 18674 formData.append('pageId', '_site'); 18675 formData.append('elementId', 'custom-public-html'); 18676 formData.append('folderPath', ''); 18677 var relUpload = typeof window.getApiUrl === 'function' ? window.getApiUrl('/api/upload-file') : '/api/upload-file'; 18678 var uploadUrl = window.location && window.location.origin ? new URL(relUpload, window.location.origin).href : relUpload; 18679 fetch(uploadUrl, { 18680 method: 'POST', 18681 headers: { 'Authorization': authToken, 'X-User-Email': localStorage.getItem('hydra_auth_email') || '' }, 18682 body: formData 18683 }).then(function(r) { 18684 return r.text().then(function(text) { 18685 var ct = (r.headers.get('content-type') || '').toLowerCase(); 18686 if (ct.indexOf('application/json') === -1 && /<!DOCTYPE/i.test(text)) { 18687 throw new Error( 18688 'Server returned HTML instead of JSON â POST /api/upload-file is not reaching the Hydra worker. ' + 18689 'In Cloudflare, ensure this hostname has a Worker route (e.g. neonsky.app/*) so API traffic is not sent to another host or Pages.' 18690 ); 18691 } 18692 if (r.status === 403 && ct.indexOf('application/json') === -1) { 18693 throw new Error( 18694 'Upload blocked (403). If this is an HTML file on preview or your domain, Cloudflare WAF may be blocking the request â add a custom rule to skip WAF for POST URI path containing /api/upload-file.' 18695 ); 18696 } 18697 try { 18698 return JSON.parse(text); 18699 } catch (parseErr) { 18700 throw new Error('Server did not return JSON: ' + text.slice(0, 120) + (text.length > 120 ? '...' : '')); 18701 } 18702 }); 18703 }).then(function(res) { 18704 if (res.success && res.key) { 18705 var keyEl = document.getElementById('customPublicSiteHtmlR2Key'); 18706 var fnEl = document.getElementById('customPublicSiteHtmlFilenameStore'); 18707 var lbl = document.getElementById('customPublicSiteHtmlFilenameLabel'); 18708 if (keyEl) keyEl.value = res.key; 18709 if (fnEl) fnEl.value = file.name; 18710 if (lbl) lbl.textContent = 'Current file: ' + file.name + ' â click Save Metadata below to apply.'; 18711 window.siteMetadata = window.siteMetadata || {}; 18712 window.siteMetadata.customPublicSiteHtmlR2Key = res.key; 18713 window.siteMetadata.customPublicSiteHtmlFilename = file.name; 18714 } else { 18715 alert(res.error || res.details || 'Upload failed'); 18716 } 18717 }).catch(function(e) { 18718 alert('Upload failed: ' + (e && e.message ? e.message : 'Unknown error')); 18719 }); 18720 }); 18721 input.click(); 18722} 18723 18724function clearCustomPublicHtmlFile() { 18725 if (!confirm('Clear the uploaded HTML file reference? (You can upload again later.)')) return; 18726 var k = document.getElementById('customPublicSiteHtmlR2Key'); 18727 var fn = document.getElementById('customPublicSiteHtmlFilenameStore'); 18728 var lbl = document.getElementById('customPublicSiteHtmlFilenameLabel'); 18729 if (k) k.value = ''; 18730 if (fn) fn.value = ''; 18731 if (lbl) lbl.textContent = 'No HTML file uploaded yet.'; 18732 if (window.siteMetadata) { 18733 window.siteMetadata.customPublicSiteHtmlR2Key = ''; 18734 window.siteMetadata.customPublicSiteHtmlFilename = ''; 18735 } 18736} 18737 18738// Handle favicon upload button click 18739document.addEventListener('DOMContentLoaded', function() { 18740 const uploadBtn = document.getElementById('uploadFaviconBtn'); 18741 if (uploadBtn) { 18742 uploadBtn.addEventListener('click', function() { 18743 uploadFavicon(); 18744 }); 18745 } 18746 const uploadOgBtn = document.getElementById('uploadOgImageBtn'); 18747 if (uploadOgBtn) { 18748 uploadOgBtn.addEventListener('click', function() { 18749 uploadSiteOgImage(); 18750 }); 18751 } 18752 var upHtml = document.getElementById('uploadCustomPublicHtmlBtn'); 18753 if (upHtml) upHtml.addEventListener('click', uploadCustomPublicHtmlFile); 18754 var rmHtml = document.getElementById('removeCustomPublicHtmlBtn'); 18755 if (rmHtml) rmHtml.addEventListener('click', clearCustomPublicHtmlFile); 18756 bindCustomPublicSiteOptionKeyListeners(); 18757 syncCustomPublicSiteBypassVisibility(); 18758 18759 // Handle content language dropdown change 18760 const contentLanguageSelect = document.getElementById('contentLanguage'); 18761 if (contentLanguageSelect) { 18762 contentLanguageSelect.addEventListener('change', function() { 18763 console.warn('Content language changed to:', this.value); 18764 // Update global variable immediately 18765 window.siteMetadata.contentLanguage = this.value; 18766 console.warn('Updated window.siteMetadata.contentLanguage:', window.siteMetadata.contentLanguage); 18767 }); 18768 } 18769 18770 const copyrightToggle = document.getElementById('metadataCopyrightEnabled'); 18771 if (copyrightToggle) { 18772 copyrightToggle.addEventListener('change', updateMetadataCopyrightFieldState); 18773 } 18774 18775 updateMetadataCopyrightFieldState(); 18776}); 18777 18778// Function to handle favicon upload using ImageUploader 18779function uploadFavicon() { 18780 console.log('Starting favicon upload...'); 18781 18782 // Check if ImageUploader is available 18783 if (!window.ImageUploader || typeof window.ImageUploader.open !== 'function') { 18784 alert('Image uploader not available. Please enter a favicon URL manually.'); 18785 return; 18786 } 18787 18788 try { 18789 // Create options for the ImageUploader 18790 const options = { 18791 title: 'Upload Favicon', 18792 containerType: 'favicon', 18793 maxWidth: 64, // Favicons are typically small 18794 maxHeight: 64, 18795 quality: 0.9 18796 }; 18797 18798 // Call the ImageUploader 18799 window.ImageUploader.open(options, function(imageResult, imageInfo) { 18800 console.log('Favicon upload callback received:', imageInfo); 18801 18802 // Get the favicon URL input field 18803 let faviconInput = document.getElementById('faviconUrl'); 18804 console.warn('=== UPLOAD CALLBACK DEBUG ==='); 18805 console.warn('Favicon input element found:', !!faviconInput); 18806 console.warn('Favicon input element type:', faviconInput ? faviconInput.type : 'N/A'); 18807 console.warn('Favicon input element id:', faviconInput ? faviconInput.id : 'N/A'); 18808 18809 // If element not found, wait a bit and try again (metadata editor might not be open yet) 18810 if (!faviconInput) { 18811 console.warn('Favicon input not found, waiting 100ms and trying again...'); 18812 setTimeout(() => { 18813 faviconInput = document.getElementById('faviconUrl'); 18814 console.warn('Retry - Favicon input element found:', !!faviconInput); 18815 if (faviconInput) { 18816 console.warn('Retry - Setting favicon input value to:', imageInfo.url); 18817 faviconInput.value = imageInfo.url; 18818 console.warn('Retry - Favicon input value after setting:', faviconInput.value); 18819 } else { 18820 console.error('Retry - Favicon input field still not found'); 18821 } 18822 }, 100); 18823 return; 18824 } 18825 18826 // Set the uploaded image URL 18827 if (imageInfo && imageInfo.type === 'r2' && imageInfo.url) { 18828 // R2 storage - use the URL 18829 console.warn('Setting favicon input value to:', imageInfo.url); 18830 faviconInput.value = imageInfo.url; 18831 console.warn('Favicon input value after setting:', faviconInput.value); 18832 18833 // Also store in global variable as backup 18834 window.uploadedFaviconUrl = imageInfo.url; 18835 console.warn('Stored favicon URL in global variable:', window.uploadedFaviconUrl); 18836 18837 console.log('Favicon URL set to:', imageInfo.url); 18838 18839 // Update button text 18840 const btnText = document.getElementById('uploadFaviconBtnText'); 18841 if (btnText) { 18842 btnText.textContent = 'Change Favicon'; 18843 } 18844 18845 // Show preview 18846 showFaviconPreview(imageInfo.url); 18847 } else if (imageResult) { 18848 // Legacy base64 storage 18849 faviconInput.value = imageResult; 18850 console.log('Favicon set to base64 data'); 18851 18852 // Update button text 18853 const btnText = document.getElementById('uploadFaviconBtnText'); 18854 if (btnText) { 18855 btnText.textContent = 'Change Favicon'; 18856 } 18857 18858 // Show preview 18859 showFaviconPreview(imageResult); 18860 } else { 18861 console.error('No image data received from uploader'); 18862 alert('Upload failed. Please try again.'); 18863 return; 18864 } 18865 18866 // Show success message 18867 console.log('Favicon uploaded successfully'); 18868 }); 18869 18870 } catch (error) { 18871 console.error('Error opening favicon uploader:', error); 18872 alert('Error opening uploader: ' + (error.message || 'Unknown error')); 18873 } 18874} 18875 18876// Function to show a preview of the uploaded favicon 18877function showFaviconPreview(imageUrl) { 18878 const container = document.getElementById('faviconPreviewContainer'); 18879 if (!container) { 18880 console.error('Favicon preview container not found'); 18881 return; 18882 } 18883 18884 container.innerHTML = 18885 '<div style="margin-top: 15px; padding: 15px; border: 1px solid #ddd; border-radius: 4px; background: #f9f9f9; display: flex; align-items: center; gap: 15px;">' + 18886 '<img src="' + imageUrl + '" alt="Favicon Preview" style="width: 32px; height: 32px; border: 1px solid #ccc; background: white; padding: 4px;">' + 18887 '<div style="flex: 1;">' + 18888 '<div style="font-size: 14px; color: #333; font-weight: 500;">Current Favicon</div>' + 18889 '<div style="font-size: 12px; color: #666; margin-top: 2px;">Preview (32x32px)</div>' + 18890 '</div>' + 18891 '<button type="button" onclick="removeFavicon()" style="background: #dc3545; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">' + 18892 'Remove' + 18893 '</button>' + 18894 '</div>'; 18895} 18896 18897// Function to remove the favicon 18898function removeFavicon() { 18899 const faviconInput = document.getElementById('faviconUrl'); 18900 if (faviconInput) { 18901 faviconInput.value = ''; 18902 } 18903 18904 const container = document.getElementById('faviconPreviewContainer'); 18905 if (container) { 18906 container.innerHTML = ''; 18907 } 18908 18909 const btnText = document.getElementById('uploadFaviconBtnText'); 18910 if (btnText) { 18911 btnText.textContent = 'Upload Favicon'; 18912 } 18913 18914 console.log('Favicon removed'); 18915} 18916 18917function uploadSiteOgImage() { 18918 if (!window.ImageUploader || typeof window.ImageUploader.open !== 'function') { 18919 alert('Image uploader not available. Refresh the page and try again.'); 18920 return; 18921 } 18922 try { 18923 window.ImageUploader.open({ 18924 title: 'Upload social preview image', 18925 containerType: 'page', 18926 quality: 0.92 18927 }, function(imageResult, imageInfo) { 18928 const inp = document.getElementById('ogImageUrl'); 18929 const setUrl = function(url) { 18930 if (inp) inp.value = url; 18931 const btnText = document.getElementById('uploadOgImageBtnText'); 18932 if (btnText) btnText.textContent = 'Change image'; 18933 showOgImagePreview(url); 18934 window.siteMetadata = window.siteMetadata || {}; 18935 window.siteMetadata.ogImageUrl = url; 18936 }; 18937 if (imageInfo && imageInfo.url) { 18938 setUrl(imageInfo.url); 18939 } else if (imageResult && typeof imageResult === 'string' && imageResult.indexOf('data:') === 0) { 18940 setUrl(imageResult); 18941 } else { 18942 alert('Upload failed. Please try again.'); 18943 } 18944 }); 18945 } catch (error) { 18946 console.error('Error opening OG image uploader:', error); 18947 alert('Error opening uploader: ' + (error.message || 'Unknown error')); 18948 } 18949} 18950 18951function showOgImagePreview(imageUrl) { 18952 const container = document.getElementById('ogImagePreviewContainer'); 18953 if (!container) return; 18954 container.innerHTML = 18955 '<div style="margin-top: 15px; padding: 15px; border: 1px solid #ddd; border-radius: 4px; background: #f9f9f9; display: flex; align-items: center; gap: 15px;">' + 18956 '<img src="' + imageUrl + '" alt="Social preview" style="max-width: 200px; max-height: 105px; width: auto; height: auto; border: 1px solid #ccc; object-fit: contain; background: white;">' + 18957 '<div style="flex: 1;">' + 18958 '<div style="font-size: 14px; color: #333; font-weight: 500;">Current social preview image</div>' + 18959 '<div style="font-size: 12px; color: #666; margin-top: 2px;">This image overrides the first page/gallery image for social previews</div>' + 18960 '</div>' + 18961 '<button type="button" onclick="removeOgImage()" style="background: #dc3545; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">' + 18962 'Remove' + 18963 '</button>' + 18964 '</div>'; 18965} 18966 18967function removeOgImage() { 18968 const inp = document.getElementById('ogImageUrl'); 18969 if (inp) inp.value = ''; 18970 if (window.siteMetadata) window.siteMetadata.ogImageUrl = ''; 18971 const container = document.getElementById('ogImagePreviewContainer'); 18972 if (container) container.innerHTML = ''; 18973 const btnText = document.getElementById('uploadOgImageBtnText'); 18974 if (btnText) btnText.textContent = 'Upload image'; 18975} 18976</script>
18976 18977 18978 <!-- Import Classic Form --> 18979<div class="import-classic-form" id="importClassicForm" style="display: none;" data-nosnippet> 18980 <h3>Import Galleries from JSON or Classic GUID</h3> 18981 <p>Paste the full site JSON into the text area below, <strong>OR</strong> enter the GUID of a classic gallery collection.</p> 18982 18983 <div class="form-group"> 18984 <label for="pastedJson">Pasted Site JSON:</label> 18985 <textarea id="pastedJson" rows="6" placeholder="Paste the entire site JSON here..."></textarea> 18986 </div> 18987 18988 <div class="import-separator">OR</div> 18989 18990 <div class="form-group"> 18991 <label for="classicGuid">Classic GUID or Import ID:</label> 18992 <input type="text" id="classicGuid" placeholder="Enter GUID (e.g., 4bd5ebf1b1418) or import ID (e.g., import_4c86452fb530b)"> 18993 <small>Use a GUID to fetch from classic site, or use import_ format to fetch from R2 storage.</small> 18994 </div> 18995 18996 <div class="form-group"> 18997 <label> 18998 <input type="checkbox" id="replaceMenuCheckbox" onchange="document.getElementById('importParent').disabled = this.checked; document.getElementById('createSubmenu').disabled = this.checked;"> 18999 Replace existing menu structure 19000 </label> 19001 <small>If checked, the current menu will be deleted and replaced with the imported items. If unchecked, imported items will be added to the current menu.</small> 19002 </div> 19003 19004 <div class="form-group"> 19005 <label for="importParent">Import under:</label> 19006 <select id="importParent"> 19007 <option value="">Top Level (No Parent)</option> 19008 </select> 19009 </div> 19010 19011 <div class="form-group"> 19012 <label> 19013 <input type="checkbox" id="createSubmenu" checked> 19014 Create submenu for all imported galleries 19015 </label> 19016 </div> 19017 19018 <div class="form-group" id="submenuTitleContainer"> 19019 <label for="submenuTitle">Submenu title:</label> 19020 <input type="text" id="submenuTitle" placeholder="e.g., Classic Galleries"> 19021 </div> 19022 19023 <div id="importStatus" class="import-status" style="display: none;"> 19024 <div class="import-progress"><div class="progress-bar"></div></div> 19025 <div class="status-message">Processing...</div> 19026 </div> 19027 19028 <div class="form-actions"> 19029 <button class="btn btn-primary" onclick="importClassicGalleries()">Import from Paste/GUID</button> 19030 <button class="btn" onclick="downloadCurrentSiteJSON()">Download Current Site JSON</button> 19031 <button class="btn" onclick="toggleImportClassicForm()">Cancel</button> 19032 </div> 19033</div> 19034 <div class="tree-container"> 19035 <div id="galleryTree" data-help-id="menu-tree"></div> 19036 </div> 19037 19038 <!-- Sidebar Elements Container will be inserted here by the sidebar-manager.js --> 19039 <div class="sidebar-elements-container"></div> 19040</div> 19041<div class="gallery-container" id="hydra-main-content" role="main" tabindex="-1"> 19042 <iframe class="gallery-iframe" id="galleryFrame" title="Podcast"></iframe> 19043</div> 19044</div> 19045 19046<!-- Cookie Notice (compliance mode â only when Display Cookie Notice is enabled) --> 19047 19048
19049<script> 19050// Reveal content once layout is applied, CSS is loaded, AND gallery is initialized 19051(function() { 19052 let hasRevealed = false; // Ensure we only reveal once per page load 19053 19054 function revealContent() { 19055 if (hasRevealed) return; // Prevent multiple calls 19056 hasRevealed = true; 19057 document.documentElement.classList.add('hydra-ready'); 19058 document.body.classList.add('hydra-ready'); 19059 console.log('â Content revealed - hydra-ready class added'); 19060 } 19061 19062 // Check if this page has a gallery container 19063 function hasGalleryContainer() { 19064 return !!document.getElementById('neon-gallery-container') || 19065 !!document.querySelector('.gallery-wrapper') || 19066 !!document.querySelector('.masonry-container'); 19067 } 19068 19069 // Check if gallery is already initialized 19070 function isGalleryInitialized() { 19071 return window.neonGalleryState?.initialized || 19072 window.neonGalleryState?.domInitialized || 19073 (window.neonGalleryUtils && window.neonGalleryUtils.isInitialized && window.neonGalleryUtils.isInitialized()); 19074 } 19075 19076 // Main reveal logic 19077 function attemptReveal() { 19078 const hasGallery = hasGalleryContainer(); 19079 const galleryInitialized = isGalleryInitialized(); 19080 19081 // If no gallery, reveal immediately after CSS loads 19082 if (!hasGallery) { 19083 if (document.readyState === 'complete') { 19084 revealContent(); 19085 } else { 19086 window.addEventListener('load', revealContent, { once: true }); 19087 } 19088 return; 19089 } 19090 19091 // If gallery exists and is initialized, reveal 19092 if (hasGallery && galleryInitialized) { 19093 revealContent(); 19094 return; 19095 } 19096 19097 // If gallery exists but not initialized, wait for initialization 19098 if (hasGallery && !galleryInitialized) { 19099 console.log('â³ Waiting for gallery initialization before revealing content...'); 19100 19101 // Listen for gallery-loaded event 19102 window.addEventListener('gallery-loaded', function() { 19103 console.log('â Gallery-loaded event received'); 19104 setTimeout(revealContent, 100); // Small delay to ensure DOM is updated 19105 }, { once: true }); 19106 19107 // Listen for page-loaded event 19108 window.addEventListener('page-loaded', function() { 19109 console.log('â Page-loaded event received'); 19110 setTimeout(revealContent, 100); 19111 }, { once: true }); 19112 19113 // Fallback: Poll for gallery initialization (max 10 seconds) 19114 let pollAttempts = 0; 19115 const maxPollAttempts = 100; // 10 seconds (100 * 100ms) 19116 const pollInterval = setInterval(function() { 19117 pollAttempts++; 19118 if (isGalleryInitialized()) { 19119 console.log('â Gallery initialized detected via polling'); 19120 clearInterval(pollInterval); 19121 revealContent(); 19122 } else if (pollAttempts >= maxPollAttempts) { 19123 console.warn('â ï¸ Gallery initialization timeout - revealing content anyway'); 19124 clearInterval(pollInterval); 19125 revealContent(); // Reveal anyway to prevent blank screen 19126 } 19127 }, 100); 19128 19129 // Safety fallback: Always reveal after 8 seconds max (prevents permanent blank screen) 19130 setTimeout(function() { 19131 if (!hasRevealed) { 19132 console.warn('â ï¸ Safety timeout reached - revealing content to prevent blank screen'); 19133 revealContent(); 19134 } 19135 }, 8000); 19136 } 19137 } 19138 19139 // Start the reveal process 19140 if (document.readyState === 'loading') { 19141 document.addEventListener('DOMContentLoaded', function() { 19142 // Wait a bit for scripts to initialize 19143 setTimeout(attemptReveal, 100); 19144 }); 19145 } else if (document.readyState === 'interactive' || document.readyState === 'complete') { 19146 // DOM already ready, wait a bit for scripts 19147 setTimeout(attemptReveal, 100); 19148 } else { 19149 // Fallback 19150 window.addEventListener('load', attemptReveal, { once: true }); 19151 } 19152})(); 19153</script>
19153 19154
19155<script> 19156// ============================================ 19157// LANGUAGE TRANSLATION SYSTEM 19158// ============================================ 19159 19160(function() { 19161 // CRITICAL: Only initialize translation system if a languages element exists 19162 // This ensures sites without languages element have ZERO overhead 19163 const checkForLanguagesElement = function() { 19164 // Wait for SidebarManager to be initialized 19165 if (!window.SidebarManager || !window.SidebarManager.elements) { 19166 return false; 19167 } 19168 19169 // Check if any languages element exists 19170 const hasLanguagesElement = window.SidebarManager.elements.some(el => el.type === 'languages'); 19171 19172 if (!hasLanguagesElement) { 19173 console.warn('No languages element found - translation system disabled'); 19174 return false; 19175 } 19176 19177 return true; 19178 }; 19179 19180 // Check for languages element, retry if SidebarManager not ready yet 19181 let initAttempts = 0; 19182 const maxAttempts = 10; 19183 19184 function tryInitialize() { 19185 if (checkForLanguagesElement()) { 19186 console.warn('â Languages element found - initializing translation system'); 19187 initializeTranslationSystem(); 19188 } else { 19189 initAttempts++; 19190 if (initAttempts < maxAttempts) { 19191 // Retry in 500ms (total max wait: 5 seconds) 19192 setTimeout(tryInitialize, 500); 19193 } else { 19194 console.warn('Translation system not needed for this site - no languages element found after 5 seconds'); 19195 } 19196 } 19197 } 19198 19199 // Start checking after initial delay 19200 setTimeout(tryInitialize, 500); 19201 19202 function initializeTranslationSystem() { 19203 console.warn('â Translation system initialized successfully'); 19204 19205 let currentLanguage = localStorage.getItem('hydra_language') || 'en'; 19206 const originalTexts = new Map(); 19207 19208 // Expose test function for debugging 19209 window.testTranslation = function(langCode) { 19210 console.warn('𧪠Manual translation test triggered for:', langCode); 19211 console.warn('ð Dispatching test event...'); 19212 window.dispatchEvent(new CustomEvent('hydra:language-change', { 19213 detail: { code: langCode || 'es', name: 'Test' } 19214 })); 19215 console.warn('â Test event dispatched - check if listener caught it above'); 19216 }; 19217 19218 console.warn('â Event listener for "hydra:language-change" is now active'); 19219 19220 // Watch for edit mode changes and restore default language 19221 const editModeObserver = new MutationObserver(function(mutations) { 19222 mutations.forEach(function(mutation) { 19223 if (mutation.attributeName === 'class') { 19224 const isEditMode = document.body.classList.contains('edit-mode-active'); 19225 if (isEditMode) { 19226 console.warn('Edit mode activated - restoring default language'); 19227 // Restore original text 19228 restoreOriginalText(); 19229 19230 // Update language buttons to show default language 19231 const languagesElement = window.SidebarManager?.elements?.find(el => el.type === 'languages'); 19232 const defaultLang = languagesElement?.languages?.find(l => l.isDefault)?.code || 'en'; 19233 19234 document.querySelectorAll('.language-button-live').forEach(btn => { 19235 if (btn.getAttribute('data-lang-code') === defaultLang) { 19236 btn.style.background = 'var(--menu-active-color, #4682B4)'; 19237 } else { 19238 btn.style.background = 'transparent'; 19239 } 19240 }); 19241 } 19242 } 19243 }); 19244 }); 19245 19246 // Start observing body for class changes 19247 if (document.body) { 19248 editModeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] }); 19249 } 19250 19251 // Watch for dynamically loaded content (galleries, pages, etc.) and translate it 19252 const contentObserver = new MutationObserver(function(mutations) { 19253 // Only process if we're not in default language and not in edit mode 19254 const languagesElement = window.SidebarManager?.elements?.find(el => el.type === 'languages'); 19255 const defaultLang = languagesElement?.languages?.find(l => l.isDefault)?.code || 'en'; 19256 const isEditMode = document.body.classList.contains('edit-mode-active'); 19257 19258 if (currentLanguage === defaultLang || isEditMode) { 19259 return; // Don't translate if in default language or edit mode 19260 } 19261 19262 // Check if new text content was added 19263 let hasNewTextContent = false;
19264 mutations.forEach(function(mutation) { 19265 if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { 19266 mutation.addedNodes.forEach(function(node) { 19267 if (node.nodeType === Node.ELEMENT_NODE) { 19268 // Check if the added element contains text we should translate 19269 const textElements = node.querySelectorAll('p, h1, h2, h3, .page-text'); 19270 if (textElements.length > 0 || (node.textContent && node.textContent.trim().length > 0)) { 19271 hasNewTextContent = true; 19272 } 19273 } 19274 }); 19275 } 19276 }); 19277 19278 if (hasNewTextContent) { 19279 console.warn('New content detected - translating to:', currentLanguage); 19280 // Debounce to avoid translating multiple times rapidly 19281 clearTimeout(window._translationDebounce); 19282 window._translationDebounce = setTimeout(function() { 19283 translatePage(currentLanguage, defaultLang); 19284 }, 300); 19285 } 19286 }); 19287 19288 // Observe the gallery container and page content areas for new content 19289 const galleryContainer = document.querySelector('.gallery-container'); 19290 if (galleryContainer) { 19291 contentObserver.observe(galleryContainer, { 19292 childList: true, 19293 subtree: true 19294 }); 19295 } 19296 19297 // Also observe the main content area 19298 const mainContent = document.querySelector('.container'); 19299 if (mainContent) { 19300 contentObserver.observe(mainContent, { 19301 childList: true, 19302 subtree: true 19303 }); 19304 } 19305 19306 // Listen for language change events 19307 window.addEventListener('hydra:language-change', async function(e) { 19308 const { code, name } = e.detail; 19309 console.warn('ð Language changed to:', code, name); 19310 19311 // CRITICAL: Ignore language changes in edit mode 19312 if (document.body.classList.contains('edit-mode-active')) { 19313 console.warn('â ï¸ Edit mode active - ignoring language change'); 19314 // Reset to default language 19315 const languagesElement = window.SidebarManager?.elements?.find(el => el.type === 'languages'); 19316 const defaultLang = languagesElement?.languages?.find(l => l.isDefault)?.code || 'en'; 19317 19318 // Update button states to show default language 19319 document.querySelectorAll('.language-button-live').forEach(btn => { 19320 if (btn.getAttribute('data-lang-code') === defaultLang) { 19321 btn.style.background = 'var(--menu-active-color, #4682B4)'; 19322 } else { 19323 btn.style.background = 'transparent'; 19324 } 19325 }); 19326 19327 // Show message to user 19328 console.warn('Language switching is disabled in edit mode'); 19329 return; 19330 } 19331 19332 // Update all language buttons 19333 document.querySelectorAll('.language-button-live').forEach(btn => { 19334 if (btn.getAttribute('data-lang-code') === code) { 19335 btn.style.background = 'var(--menu-active-color, #4682B4)'; 19336 } else { 19337 btn.style.background = 'transparent'; 19338 } 19339 }); 19340 19341 // Save preference 19342 localStorage.setItem('hydra_language', code); 19343 currentLanguage = code; 19344 19345 // Get default language from languages element 19346 const languagesElement = window.SidebarManager?.elements?.find(el => el.type === 'languages'); 19347 const defaultLang = languagesElement?.languages?.find(l => l.isDefault)?.code || 'en'; 19348 19349 if (code === defaultLang) { 19350 // Restore original language 19351 restoreOriginalText(); 19352 } else { 19353 // Translate page 19354 await translatePage(code, defaultLang); 19355 } 19356 }); 19357 19358 async function translatePage(targetLang, sourceLang) { 19359 console.warn(`ð Translating page from ${sourceLang} to ${targetLang}`); 19360 19361 // Elements to translate 19362 const selectors = [ 19363 '.sidebar li a:not(.edit-mode-active .sidebar li a)', // Menu items (not in edit mode) 19364 '.page-text:not(.edit-mode-active .page-text)', // Page content (not in edit mode) 19365 'h1:not(.edit-mode-active h1)', 19366 'h2:not(.edit-mode-active h2)', 19367 'h3:not(.edit-mode-active h3)', 19368 'p:not(.edit-mode-active p)' 19369 ]; 19370 19371 const elements = document.querySelectorAll(selectors.join(', ')); 19372 19373 for (const element of elements) { 19374 // Skip if already translated to this language 19375 if (element.dataset.translatedTo === targetLang) continue; 19376 19377 // Skip if element is empty 19378 const text = element.textContent.trim(); 19379 if (!text || text.length === 0) continue; 19380 19381 // Store original text 19382 if (!originalTexts.has(element)) { 19383 originalTexts.set(element, text); 19384 } 19385 19386 // Check translation cache 19387 const cacheKey = `translate_${text}_${targetLang}`; 19388 let translated = getFromCache(cacheKey); 19389 19390 if (translated) { 19391 element.textContent = translated; 19392 element.dataset.translatedTo = targetLang; 19393 } else { 19394 // Translate 19395 try { 19396 translated = await translate(text, sourceLang, targetLang); 19397 if (translated && translated !== text) { 19398 element.textContent = translated; 19399 element.dataset.translatedTo = targetLang; 19400 saveToCache(cacheKey, translated); 19401 } 19402 } catch (error) { 19403 console.error('Translation error:', error); 19404 } 19405 } 19406 } 19407 } 19408 19409 function restoreOriginalText() {
19410 originalTexts.forEach((originalText, element) => { 19411 if (element && element.isConnected) { 19412 element.textContent = originalText; 19413 delete element.dataset.translatedTo; 19414 } 19415 }); 19416 } 19417 19418 async function translate(text, sourceLang, targetLang) { 19419 if (!text || text.trim().length === 0) return text; 19420 19421 // Try Browser Translation API (Free, Chrome 130+) 19422 if ('translation' in self && 'createTranslator' in self.translation) { 19423 try { 19424 console.warn('ð Using Browser Translation API'); 19425 const translator = await self.translation.createTranslator({ 19426 sourceLanguage: sourceLang, 19427 targetLanguage: targetLang 19428 }); 19429 const result = await translator.translate(text); 19430 console.warn('â Translation successful via Browser API'); 19431 return result; 19432 } catch (error) { 19433 console.warn('â Browser translation failed:', error); 19434 } 19435 } else { 19436 console.warn('â ï¸ Browser Translation API not available'); 19437 console.warn(' To enable in Chrome: chrome://flags/#translation-api'); 19438 console.warn(' OR add Google Translate API key to use paid translation'); 19439 } 19440 19441 // TODO: Add Google Translate API fallback here 19442 // For now, return original text 19443 console.warn('â ï¸ No translation provider configured - returning original text'); 19444 return text; 19445 } 19446 19447 // Cache management 19448 function getFromCache(key) { 19449 try { 19450 const item = localStorage.getItem(`hydra_${key}`); 19451 if (!item) return null; 19452 19453 const { value, expiry } = JSON.parse(item); 19454 if (expiry && Date.now() > expiry) { 19455 localStorage.removeItem(`hydra_${key}`); 19456 return null; 19457 } 19458 return value; 19459 } catch { 19460 return null; 19461 } 19462 } 19463 19464 function saveToCache(key, value) { 19465 try { 19466 const item = { 19467 value, 19468 expiry: Date.now() + (30 * 24 * 60 * 60 * 1000) // 30 days 19469 }; 19470 localStorage.setItem(`hydra_${key}`, JSON.stringify(item)); 19471 } catch (error) { 19472 console.warn('Cache save failed:', error); 19473 } 19474 } 19475 19476 } // End of initializeTranslationSystem 19477})(); 19478</script>
19478 19479 19480 19481
19482<script> 19483window.HYDRA_HELP_ENABLED = true; 19484function loadHelpSystemScript() { 19485 if (window.HelpSystem && window.HelpSystem.openHub) return Promise.resolve(); 19486 return new Promise(function(resolve, reject) { 19487 var existing = document.querySelector('script[data-hydra-help-system]'); 19488 if (existing) { 19489 existing.addEventListener('load', function() { resolve(); }); 19490 existing.addEventListener('error', reject); 19491 return; 19492 } 19493 var s = document.createElement('script'); 19494 s.src = '/api/help-system.js?v=686c85345749'; 19495 s.async = true; 19496 s.setAttribute('data-hydra-help-system', '1'); 19497 s.onload = function() { resolve(); }; 19498 s.onerror = reject; 19499 document.head.appendChild(s); 19500 }); 19501} 19502function openHydraHelp(opts) { 19503 opts = opts || {}; 19504 if (!window.HYDRA_HELP_ENABLED) return; 19505 var token = window.didToken || localStorage.getItem('hydra_auth_token'); 19506 var isAdmin = window.isAdmin || localStorage.getItem('hydra_is_admin') === 'true'; 19507 if (!token || !isAdmin) return; 19508 var p = loadHelpSystemScript(); 19509 Promise.resolve(p).then(function() { 19510 if (window.HelpSystem && window.HelpSystem.init) return window.HelpSystem.init(); 19511 }).then(function() { 19512 if (window.HelpSystem && window.HelpSystem.openHub) { 19513 window.HelpSystem.openHub(opts.initialTab ? { initialTab: opts.initialTab } : undefined); 19514 } 19515 }).catch(function(err) { 19516 console.error('HelpSystem init failed:', err); 19517 }); 19518} 19519window.openHydraHelp = openHydraHelp; 19520</script>
19520 19521
19522<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/v31edd6df95cf4e85bb4c19e7a9bdbcba1788362987495" integrity="sha512-iIg7k2xntmwu6/uSb5tpc/hySgZc4eoL31yB29W6tJFo2akwjPWcEqnCEdJvGexCL0KEQwVYv5BlowfhVz26hg==" data-cf-beacon='{"version":"2024.11.0","token":"5f8c17677ba84f8d88ba3d46ef47b608","spa":2}' crossorigin="anonymous"></script>
19522 19523</body> 19524</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.