4sp-dv 1.0.37 → 1.0.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
  <title>4SP - VERSION 5 CLIENT</title>
7
7
  <link rel="icon" type="image/png" href="https://cdn.jsdelivr.net/npm/4sp-dv@latest/images/logo.png">
8
8
 
9
- <base href="https://cdn.jsdelivr.net/npm/4sp-dv@1.0.36/logged-in/">
9
+ <base href="https://cdn.jsdelivr.net/npm/4sp-dv@1.0.38/logged-in/">
10
10
  <script src="https://cdn.tailwindcss.com"></script>
11
11
  <link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap" rel="stylesheet">
12
12
 
@@ -468,33 +468,9 @@
468
468
  #universal-loader.active {
469
469
  pointer-events: auto;
470
470
  }
471
-
472
- /* Offline Indicator */
473
- #offline-indicator {
474
- position: fixed;
475
- top: 1rem;
476
- left: 50%;
477
- transform: translateX(-50%);
478
- background: rgba(239, 68, 68, 0.2);
479
- border: 1px solid rgba(239, 68, 68, 0.5);
480
- color: #f87171;
481
- padding: 4px 12px;
482
- border-radius: 20px;
483
- font-size: 0.75rem;
484
- z-index: 9999;
485
- display: none;
486
- align-items: center;
487
- gap: 6px;
488
- backdrop-filter: blur(4px);
489
- }
490
- #offline-indicator.show { display: flex; }
491
471
  </style>
492
472
  </head>
493
473
  <body>
494
- <div id="offline-indicator">
495
- <i class="fa-solid fa-plane"></i>
496
- <span>Offline Mode</span>
497
- </div>
498
474
  <div id="lock-screen" class="w-full h-full flex flex-col items-center justify-center p-4">
499
475
  <div class="max-w-5xl w-full bg-[#040404] flex flex-col md:flex-row border border-[#252525] rounded-3xl overflow-hidden shadow-2xl">
500
476
  <div class="flex-1 p-8 flex flex-col justify-center items-center border-b md:border-b-0 md:border-r border-[#252525]">
@@ -802,7 +778,7 @@
802
778
  const displayUsername = document.getElementById('display-username');
803
779
  const displayEmail = document.getElementById('display-email');
804
780
 
805
- const BASE_URL = 'https://cdn.jsdelivr.net/npm/4sp-dv@1.0.36/logged-in/';
781
+ const BASE_URL = 'https://cdn.jsdelivr.net/npm/4sp-dv@1.0.38/logged-in/';
806
782
 
807
783
  // Preload Logos
808
784
  const preloadImgs = [
@@ -1213,8 +1189,7 @@
1213
1189
  pfpLetterBg: data.pfpLetterBg,
1214
1190
  letterAvatarText: data.letterAvatarText,
1215
1191
  letterAvatarTextColor: data.letterAvatarTextColor, // New Field
1216
- navbarTheme: data.navbarTheme, // Sync theme if updated remotely
1217
- verified_online: true // Mark as verified for offline use
1192
+ navbarTheme: data.navbarTheme // Sync theme if updated remotely
1218
1193
  };
1219
1194
 
1220
1195
  localStorage.setItem(USER_DATA_KEY, JSON.stringify(newUserData));
@@ -1305,25 +1280,6 @@
1305
1280
  unlockBtn.classList.add('hidden');
1306
1281
  codeInput.classList.add('hidden');
1307
1282
 
1308
- // --- OFFLINE CHECK ---
1309
- if (!navigator.onLine) {
1310
- console.log("Client is offline. Checking for bypass...");
1311
- let cachedUser = null;
1312
- try { cachedUser = JSON.parse(localStorage.getItem(USER_DATA_KEY)); } catch(e){}
1313
-
1314
- if (cachedUser && cachedUser.verified_online) {
1315
- console.log("Offline bypass granted for verified user.");
1316
- currentUser = cachedUser;
1317
- updateUIWithUser(cachedUser);
1318
- document.getElementById('offline-indicator').classList.add('show');
1319
- launchApp();
1320
- return;
1321
- } else {
1322
- resetLockScreen("You are offline. Please connect to the internet to verify your code.");
1323
- return;
1324
- }
1325
- }
1326
-
1327
1283
  try {
1328
1284
  const docRef = doc(db, "access_codes", savedCode);
1329
1285
  const docSnap = await getDoc(docRef);
@@ -1355,17 +1311,7 @@
1355
1311
  }
1356
1312
  } catch(e) {
1357
1313
  console.error("Auto-login error:", e);
1358
- // Check if we can fallback to offline even on error (e.g. timeout)
1359
- let cachedUser = null;
1360
- try { cachedUser = JSON.parse(localStorage.getItem(USER_DATA_KEY)); } catch(err){}
1361
- if (cachedUser && cachedUser.verified_online) {
1362
- currentUser = cachedUser;
1363
- updateUIWithUser(cachedUser);
1364
- document.getElementById('offline-indicator').classList.add('show');
1365
- launchApp();
1366
- } else {
1367
- resetLockScreen("Connection failed. Retrying...");
1368
- }
1314
+ resetLockScreen("Connection failed. Retrying...");
1369
1315
  }
1370
1316
  }
1371
1317
  }
@@ -331,16 +331,6 @@
331
331
  .custom-scrollbar::-webkit-scrollbar-track { background: rgba(0,0,0,0.3); border-radius: 4px; }
332
332
  .custom-scrollbar::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; }
333
333
  .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #555; }
334
-
335
- /* Notifications */
336
- #notification-container { position: fixed; bottom: 2rem; right: 2rem; display: flex; flex-direction: column; gap: 0.75rem; z-index: 9999; pointer-events: none; }
337
- .notification-toast { background-color: #0a0a0a; border: 1px solid #333; border-radius: 14px; padding: 0.75rem 1.25rem; color: #fff; box-shadow: 0 4px 15px rgba(0,0,0,0.5); display: flex; align-items: center; gap: 0.75rem; font-size: 0.9rem; min-width: 200px; transform: translateX(120%); transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.3s ease, background-color 0.2s; opacity: 0; pointer-events: auto; cursor: default; }
338
- .notification-toast.show { transform: translateX(0); opacity: 1; }
339
- .notification-toast.show:hover { transform: scale(1.05) translateX(-5px); background-color: #151515; border-color: #555; box-shadow: 0 8px 25px rgba(0,0,0,0.7); }
340
- .notification-icon { font-size: 1.1rem; }
341
- .notification-icon.success { color: #4ade80; }
342
- .notification-icon.info { color: #60a5fa; }
343
- .notification-icon.warning { color: #fbbf24; }
344
334
  </style>
345
335
  </head>
346
336
 
@@ -460,16 +450,19 @@
460
450
  </div>
461
451
  </div>
462
452
 
463
- <div id="notification-container"></div>
464
-
465
453
  <script>
466
454
  // --- Configuration & Globals ---
455
+
467
456
  const GN_ZONES_URL = "https://cdn.jsdelivr.net/gh/gn-math/assets@main/zones.json";
468
457
  const GN_COVER_URL_BASE = "https://cdn.jsdelivr.net/gh/gn-math/covers@main";
458
+
459
+ // FIXED: Use raw.githack.com for HTML to prevent "showing code" issue
469
460
  const GN_HTML_URL_BASE = "https://raw.githack.com/gn-math/html/main";
461
+
470
462
  const WORKER_ROOT = "https://dv-service-lfs.4simpleproblems.workers.dev/";
471
463
  const GAMES_BASE_URL = "https://dv-service-lfs.4simpleproblems.workers.dev/GAMES/";
472
464
  const CARDS_DATA_URL = "https://dv-service-lfs.4simpleproblems.workers.dev/GAMES/cards-data.js";
465
+
473
466
  const FAVORITES_KEY = 'gameHubFavorites_v3';
474
467
 
475
468
  let allGames = [];
@@ -499,26 +492,37 @@
499
492
  const prevCategoryBtn = document.getElementById('prev-category');
500
493
  const nextCategoryBtn = document.getElementById('next-category');
501
494
  const gamesGridContainer = document.getElementById('games-grid-container');
495
+
502
496
  const creditsModal = document.getElementById('creditsModal');
503
497
  const creditsContent = document.getElementById('creditsContent');
504
498
  const creditsBtn = document.getElementById('creditsBtn');
505
499
  const closeCreditsBtn = document.getElementById('closeCreditsBtn');
500
+
506
501
  const INSTRUCTION_KEY = '4sp-games-instruction-extended-seen';
507
502
  const instructionOverlay = document.getElementById('instruction-overlay');
508
503
  const instructionInput = document.getElementById('instruction-input');
509
504
 
510
- // --- Intersection Observer ---
505
+ // --- Intersection Observer for Image Virtualization ---
511
506
  const imageObserver = new IntersectionObserver((entries, observer) => {
512
507
  entries.forEach(entry => {
513
508
  const img = entry.target;
514
509
  if (entry.isIntersecting) {
515
- if (img.dataset.src) img.src = img.dataset.src;
510
+ if (img.dataset.src) {
511
+ img.src = img.dataset.src;
512
+ }
516
513
  } else {
517
- if(img.src && !img.src.includes('placehold.co') && !img.dataset.src) img.dataset.src = img.src;
518
- if(img.dataset.src) img.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
514
+ if(img.src && !img.src.includes('placehold.co') && !img.dataset.src) {
515
+ img.dataset.src = img.src;
516
+ }
517
+ if(img.dataset.src) {
518
+ img.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
519
+ }
519
520
  }
520
521
  });
521
- }, { rootMargin: "300px 0px", threshold: 0.01 });
522
+ }, {
523
+ rootMargin: "300px 0px",
524
+ threshold: 0.01
525
+ });
522
526
 
523
527
  function observeImages(container) {
524
528
  const images = container.querySelectorAll('img[data-src]');
@@ -529,7 +533,11 @@
529
533
  function updateURL(category, gameId = null) {
530
534
  let hash = `#${encodeURIComponent(category.replace(/\s+/g, '-'))}`;
531
535
  if (gameId) hash += `?id=${gameId}`;
532
- try { history.replaceState(null, null, hash); } catch(e) { console.warn('History API not supported'); }
536
+ try {
537
+ history.replaceState(null, null, hash);
538
+ } catch(e) {
539
+ console.warn('History API not supported in this environment');
540
+ }
533
541
  }
534
542
 
535
543
  function parseURL() {
@@ -537,6 +545,7 @@
537
545
  if (!hash) return { category: null, gameId: null };
538
546
  const parts = hash.split('?');
539
547
  let rawCategory = parts[0];
548
+ let category = decodeURIComponent(rawCategory).replace(/-/g, ' ');
540
549
  let gameId = null;
541
550
  if (parts[1]) {
542
551
  const params = new URLSearchParams(parts[1]);
@@ -567,7 +576,7 @@
567
576
  }
568
577
  }
569
578
 
570
- // --- Favorites ---
579
+ // --- Favorites Management (Live Updates) ---
571
580
  const getFavorites = () => JSON.parse(localStorage.getItem(FAVORITES_KEY)) || [];
572
581
  const saveFavorites = (favs) => localStorage.setItem(FAVORITES_KEY, JSON.stringify(favs));
573
582
 
@@ -580,9 +589,9 @@
580
589
  favorites = favorites.filter(id => id !== strGameId);
581
590
  saveFavorites(favorites);
582
591
  updateAllFavoriteButtons();
583
- const favCard = favoritesGameList.querySelector(`[data-game-id='${strGameId}']`);
592
+ const favCard = favoritesGameList.querySelector(`.zone-item[data-game-id='${strGameId}'], .other-zone-item[data-game-id='${strGameId}']`);
584
593
  if (favCard) {
585
- favCard.classList.add('animate-fade-out');
594
+ favCard.classList.add('fade-out');
586
595
  setTimeout(() => {
587
596
  favCard.remove();
588
597
  if (favoritesGameList.children.length === 0) {
@@ -595,11 +604,25 @@
595
604
  favorites.push(strGameId);
596
605
  saveFavorites(favorites);
597
606
  updateAllFavoriteButtons();
598
- let gameData = searchableGames.find(g => String(g.id) === strGameId);
607
+ let gameData = allGames.find(g => String(g.id) === strGameId);
608
+ if (!gameData) {
609
+ allGames.some(g => {
610
+ if (g.versions) {
611
+ const ver = g.versions.find(v => String(v.favoriteId) === strGameId);
612
+ if (ver) {
613
+ gameData = { ...g, id: ver.favoriteId, name: `${g.name} - ${ver.name}`, url: ver.url, versions: undefined };
614
+ return true;
615
+ }
616
+ }
617
+ return false;
618
+ });
619
+ }
599
620
  if (gameData) {
600
621
  favoritesHeader.style.display = "block";
601
622
  favoritesGameList.style.display = "grid";
602
- favoritesGameList.appendChild(createGameCard(gameData, true));
623
+ favoritesGameList.classList.add('grid', 'grid-cols-2', 'sm:grid-cols-3', 'md:grid-cols-5', 'lg:grid-cols-7', 'gap-4');
624
+ const newCard = createGameCard(gameData, true);
625
+ favoritesGameList.appendChild(newCard);
603
626
  }
604
627
  }
605
628
  }
@@ -607,84 +630,37 @@
607
630
  function updateAllFavoriteButtons() {
608
631
  const favorites = getFavorites().map(String);
609
632
  document.querySelectorAll('.btn-card-action.fav-action').forEach(btn => {
610
- const card = btn.closest('[data-game-id]');
633
+ const card = btn.closest('.zone-item, .other-zone-item, .search-item');
611
634
  if (!card) return;
612
635
  const cardGameId = String(card.dataset.gameId);
613
- const isFavorited = favorites.includes(cardGameId);
614
- btn.classList.toggle('favorited', isFavorited);
615
- btn.title = isFavorited ? 'Remove from Favorites' : 'Add to Favorites';
616
- });
617
- }
618
-
619
- // --- Offline Mode (IndexedDB) ---
620
- const DB_NAME = '4SP_OfflineGames';
621
- const DB_VERSION = 1;
622
- const STORE_NAME = 'games';
623
-
624
- function openDB() {
625
- return new Promise((resolve, reject) => {
626
- const request = indexedDB.open(DB_NAME, DB_VERSION);
627
- request.onupgradeneeded = (e) => {
628
- const db = e.target.result;
629
- if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME, { keyPath: 'id' });
630
- };
631
- request.onsuccess = (e) => resolve(e.target.result);
632
- request.onerror = (e) => reject(e.target.error);
633
- });
634
- }
635
-
636
- async function saveGameOffline(gameId, name, url) {
637
- try {
638
- const res = await fetch(url);
639
- if (!res.ok) throw new Error('Fetch failed');
640
- const html = await res.text();
641
- const db = await openDB();
642
- return new Promise((resolve, reject) => {
643
- const tx = db.transaction(STORE_NAME, 'readwrite');
644
- const store = tx.objectStore(STORE_NAME);
645
- store.put({ id: String(gameId), name, html, timestamp: Date.now() });
646
- tx.oncomplete = () => { db.close(); resolve(); };
647
- tx.onerror = () => reject(tx.error);
648
- });
649
- } catch (e) { console.error("Offline Save Error:", e); throw e; }
650
- }
651
-
652
- async function getOfflineGame(gameId) {
653
- const db = await openDB();
654
- return new Promise((resolve, reject) => {
655
- const tx = db.transaction(STORE_NAME, 'readonly');
656
- const store = tx.objectStore(STORE_NAME);
657
- const request = store.get(String(gameId));
658
- request.onsuccess = () => { db.close(); resolve(request.result); };
659
- request.onerror = () => reject(request.error);
660
- });
661
- }
662
-
663
- async function removeOfflineGame(gameId) {
664
- const db = await openDB();
665
- return new Promise((resolve, reject) => {
666
- const tx = db.transaction(STORE_NAME, 'readwrite');
667
- const store = tx.objectStore(STORE_NAME);
668
- store.delete(String(gameId));
669
- tx.oncomplete = () => { db.close(); resolve(); };
670
- tx.onerror = () => reject(tx.error);
636
+ if (btn.classList.contains('version-favorite-btn')) {
637
+ const isAnyVersionFavorited = favorites.some(favId => favId.startsWith(cardGameId + '_'));
638
+ btn.classList.toggle('favorited', isAnyVersionFavorited);
639
+ } else {
640
+ const isFavorited = favorites.includes(cardGameId);
641
+ btn.classList.toggle('favorited', isFavorited);
642
+ btn.title = isFavorited ? 'Remove from Favorites' : 'Add to Favorites';
643
+ }
671
644
  });
672
645
  }
673
646
 
674
- async function isGameOffline(gameId) {
675
- const game = await getOfflineGame(gameId);
676
- return !!game;
677
- }
678
-
679
- // --- URL Helpers ---
647
+ // --- URL Helpers (Strongdog) ---
648
+ // Modified to support injected URLs using WORKER_ROOT + directory logic
680
649
  function getAdjustedUrls(imgSrc, page) {
681
- if (page && page > 1) return { adjustedImgSrc: `${WORKER_ROOT}strongdog${page}/img/${imgSrc}` };
650
+ if (page && page > 1) {
651
+ // ../strongdog2/img/... -> exists outside GAMES dir
652
+ return { adjustedImgSrc: `${WORKER_ROOT}strongdog${page}/img/${imgSrc}` };
653
+ }
654
+ // ./img -> assumed inside GAMES dir or wherever relative
682
655
  return { adjustedImgSrc: `${GAMES_BASE_URL}img/${imgSrc}` };
683
656
  }
684
657
 
685
658
  function sd_getBaseURLForPage(page) {
659
+ // Updated to point to WORKER_ROOT for numbered pages, GAMES_BASE_URL for default
686
660
  if (page > 1) return `${WORKER_ROOT}strongdog${page}/`;
687
- return `${WORKER_ROOT}STRONGDOG/`;
661
+ return `${WORKER_ROOT}STRONGDOG/`; // Assumes default strongdog is at root/STRONGDOG? Or inside GAMES?
662
+ // Re-reading logic: "stuff like ../DOOM/ will stay in GAMES directory but strongdog# directories exit"
663
+ // Let's assume default STRONGDOG is adjacent to strongdog2 etc. at root level based on prior naming conventions
688
664
  }
689
665
 
690
666
  async function sd_getEmbedPath(adjustedHref, originalHref, page) {
@@ -702,52 +678,73 @@
702
678
  }
703
679
  }
704
680
  } catch (error) {}
705
- for (const path of pathsToTry) { if (await sd_fileExists(path)) return path; }
681
+ for (const path of pathsToTry) {
682
+ if (await sd_fileExists(path)) return path;
683
+ }
706
684
  return adjustedHref;
707
685
  }
708
- async function sd_fileExists(url) { try { const response = await fetch(url, { method: "HEAD" }); return response.ok; } catch { return false; } }
686
+ async function sd_fileExists(url) {
687
+ try { const response = await fetch(url, { method: "HEAD" }); return response.ok; } catch { return false; }
688
+ }
709
689
 
690
+ // --- URL Resolution Helper for "Others" ---
710
691
  function resolveGameUrl(relativePath) {
711
- if (relativePath.startsWith('../')) return `${WORKER_ROOT}${relativePath.substring(3)}`;
692
+ // Logic:
693
+ // If path starts with ../ -> Go to WORKER_ROOT + path (stripping ../)
694
+ // If path starts with ../GAMES/ -> It will effectively be WORKER_ROOT/GAMES/ (which matches GAMES_BASE_URL)
695
+ // This allows us to access root folders like ../GTA-JSDOS/ while keeping ../GAMES/sm64 intact.
696
+
697
+ if (relativePath.startsWith('../')) {
698
+ const pathPart = relativePath.substring(3); // Remove '../'
699
+ return `${WORKER_ROOT}${pathPart}`;
700
+ }
701
+
702
+ // If it doesn't start with ../, assume it's relative to GAMES base
712
703
  return `${GAMES_BASE_URL}${relativePath.replace(/^\.\//, '')}`;
713
704
  }
714
705
 
706
+ // --- Game Data (Others) ---
707
+ // We keep the original relative paths here so the resolver logic above can handle them dynamically
715
708
  const othersGames = [
716
709
  { id: "other-eaglercraft", name: "Eaglercraft", imgSrc: "./images/eaglercraft.png", description: "The classic survival and building game with authentic block-based physics and multiplayer support, accessible directly in the browser.", category: "Others", versions: [ { name: "Release 1.12.2", url: "../EAGLERCRAFT/eaglercraft-1.12.2.html", favoriteId: "other-eaglercraft_1.12.2" }, { name: "Release 1.12.2 WASM", url: "../EAGLERCRAFT/eaglercraft-1.12.2-wasm.html", favoriteId: "other-eaglercraft_wasm", wasm: true } ] },
717
710
  { id: "other-gta", name: "Grand Theft Auto", imgSrc: "./images/gta.png", description: "The original top-down classic. Cause chaos, complete missions, or just go for a drive in three iconic cities.", category: "Others", versions: [ { name: "Carnage3D", url: "../CARNAGE3D/web/carnage3D.html", favoriteId: "other-gta_carnage3d" }, { name: "Grand Theft Auto (JS-DOS)", url: "../GTA-JSDOS/index.html", favoriteId: "other-gta_jsdos" } ] },
718
711
  { id: "other-simcity", name: "Sim City", imgSrc: "./images/simcity2000.png", description: "The original city-building classics. Design, build, and manage the city of your dreams in either the original Sim City or the detailed Sim City 2000.", category: "Others", versions: [ { name: "Sim City", url: "../SIM-CITY/index.html", favoriteId: "other-simcity_1" }, { name: "Sim City 2000", url: "../SIM-CITY-2/index.html", favoriteId: "other-simcity_2000" } ] },
719
- { id: "other-doom", name: "DOOM", imgSrc: "./images/doom.png", description: "The legendary first-person shooter that pioneered the genre, focusing on blasting through hordes of demons in a timeless, adrenaline-fueled classic.", category: "Others", url: "../DOOM/index.html" }
712
+ { id: "other-doom", name: "DOOM", imgSrc: "./images/doom.png", description: "The legendary first-person shooter that pioneered the genre, focusing on blasting through hordes of demons in a timeless, adrenaline-fueled classic.", category: "Others", url: "../DOOM/index.html" },
713
+ { id: "other-sm64", name: "Super Mario 64", imgSrc: "./images/sm64.png", description: "The iconic 3D platformer that revolutionized gaming. Run, jump, and triple-jump your way through vast worlds to collect stars and save the princess.", category: "Others", url: "../GAMES/sm64/index.html" }
720
714
  ];
721
715
 
722
716
  // --- Card Creation ---
717
+
723
718
  function createOthersGameCard(game) {
724
719
  const favorites = getFavorites().map(String);
725
720
  const isAnyFavorite = game.versions ? favorites.some(favId => game.versions.some(v => String(v.favoriteId) === favId)) : favorites.includes(String(game.id));
726
721
  const card = document.createElement("div");
727
722
  card.className = 'other-zone-item bg-card-dark rounded-2xl border border-brand-border col-span-full shadow-lg';
728
723
  card.dataset.gameId = game.id;
724
+
725
+ // Resolve Image URL
729
726
  const resolvedImgSrc = resolveGameUrl(game.imgSrc);
730
- let gameButtonsHtml = '', favoriteButtonHtml = '', offlineButtonHtml = '';
731
- const isEaglercraft = game.id === 'other-eaglercraft';
732
727
 
728
+ let gameButtonsHtml = '';
729
+ let favoriteButtonHtml = '';
730
+
733
731
  if (game.versions && game.versions.length > 0) {
734
732
  const availableVersions = game.versions;
735
733
  if (availableVersions.length === 1) {
736
734
  const vUrl = resolveGameUrl(availableVersions[0].url);
737
- gameButtonsHtml = `<button class="btn-card-action play-action" data-url="${vUrl}" data-version-name="${availableVersions[0].name}" title="Play Game"><i class="fa-solid fa-play"></i></button>`;
735
+ gameButtonsHtml = `<button class="btn-card-action play-action" data-url="${vUrl}" data-version-name="${availableVersions[0].name}" title="Play Game"><i class="fa-solid fa-play transition-colors"></i></button>`;
738
736
  } else {
739
- const dropdownLinks = availableVersions.map(v => `<a href="#" class="eagler-dropdown-link" data-url="${resolveGameUrl(v.url)}" data-version-name="${v.name}">${v.name}</a>`).join('');
740
- gameButtonsHtml = `<div class="relative"><button class="btn-card-action version-btn" title="Select Version"><i class="fa-solid fa-chevron-up"></i></button><div class="eagler-dropdown">${dropdownLinks}</div></div>`;
737
+ const dropdownLinks = availableVersions.map(v => {
738
+ const vUrl = resolveGameUrl(v.url);
739
+ return `<a href="#" class="eagler-dropdown-link" data-url="${vUrl}" data-version-name="${v.name}">${v.name}</a>`
740
+ }).join('');
741
+ gameButtonsHtml = `<div class="relative"><button class="btn-card-action version-btn" title="Select Version"><i class="fa-solid fa-chevron-up transition-colors"></i></button><div class="eagler-dropdown">${dropdownLinks}</div></div>`;
741
742
  }
742
- if (isEaglercraft) {
743
- offlineButtonHtml = `<div class="relative"><button class="btn-card-action offline-action version-offline-btn" title="Offline Mode"><i class="fa-solid fa-cloud"></i></button><div class="eagler-dropdown">${game.versions.map(v => `<a href="#" class="eagler-dropdown-link offline-link" data-game-id="${v.favoriteId}" data-url="${resolveGameUrl(v.url)}" data-name="${game.name} - ${v.name}">${v.name}</a>`).join('')}</div></div>`;
744
- }
745
- favoriteButtonHtml = `<div class="relative"><button class="btn-card-action fav-action version-favorite-btn ${isAnyFavorite ? 'favorited' : ''}" title="Favorite a version"><i class="fa-solid fa-star fa-solid-star"></i><i class="fa-regular fa-star fa-regular-star"></i></button><div class="eagler-dropdown">${game.versions.map(v => `<a href="#" class="eagler-dropdown-link favorite-link" data-favorite-id="${v.favoriteId}">${v.name}</a>`).join('')}</div></div>`;
743
+ favoriteButtonHtml = `<div class="relative"><button class="btn-card-action fav-action version-favorite-btn ${isAnyFavorite ? 'favorited' : ''}" title="Favorite a version"><i class="fa-solid fa-star fa-solid-star transition-colors"></i><i class="fa-regular fa-star fa-regular-star transition-colors"></i></button><div class="eagler-dropdown">${game.versions.map(v => `<a href="#" class="eagler-dropdown-link favorite-link" data-favorite-id="${v.favoriteId}">${v.name}</a>`).join('')}</div></div>`;
746
744
  } else {
747
745
  const gUrl = resolveGameUrl(game.url);
748
- gameButtonsHtml = `<button class="btn-card-action play-action" data-url="${gUrl}" title="Play Game"><i class="fa-solid fa-play"></i></button>`;
749
- if (isEaglercraft) offlineButtonHtml = `<button class="btn-card-action offline-action" data-game-id="${game.id}" data-url="${gUrl}" data-name="${game.name}" title="Enable Offline Mode"><i class="fa-solid fa-cloud"></i></button>`;
750
- favoriteButtonHtml = `<button class="btn-card-action fav-action ${isAnyFavorite ? 'favorited' : ''}" title="${isAnyFavorite ? 'Remove from Favorites' : 'Add to Favorites'}"><i class="fa-solid fa-star fa-solid-star"></i><i class="fa-regular fa-star fa-regular-star"></i></button>`;
746
+ gameButtonsHtml = `<button class="btn-card-action play-action" data-url="${gUrl}" title="Play Game"><i class="fa-solid fa-play transition-colors"></i></button>`;
747
+ favoriteButtonHtml = `<button class="btn-card-action fav-action ${isAnyFavorite ? 'favorited' : ''}" title="${isAnyFavorite ? 'Remove from Favorites' : 'Add to Favorites'}"><i class="fa-solid fa-star fa-solid-star transition-colors"></i><i class="fa-regular fa-star fa-regular-star transition-colors"></i></button>`;
751
748
  }
752
749
 
753
750
  card.innerHTML = `
@@ -758,7 +755,8 @@
758
755
  <div class="flex items-start justify-between w-full">
759
756
  <h3 class="text-4xl font-bold text-white truncate drop-shadow-lg" style="max-width: 80%;" title="${game.name}">${game.name}</h3>
760
757
  <div class="flex items-center gap-1.5 bg-black/50 backdrop-blur-sm rounded-2xl p-1.5">
761
- ${gameButtonsHtml} ${offlineButtonHtml} ${favoriteButtonHtml}
758
+ ${gameButtonsHtml}
759
+ ${favoriteButtonHtml}
762
760
  </div>
763
761
  </div>
764
762
  <p class="text-lg text-white font-medium drop-shadow-lg" style="max-width: 50%;">${game.description}</p>
@@ -766,59 +764,53 @@
766
764
  </div>
767
765
  `;
768
766
 
769
- if (!game.versions) { card.querySelector('.group').addEventListener('click', () => openZone({...game, url: resolveGameUrl(game.url)})); }
767
+ if (!game.versions) {
768
+ card.querySelector('.group').addEventListener('click', () => {
769
+ const gUrl = resolveGameUrl(game.url);
770
+ openZone({...game, url: gUrl});
771
+ });
772
+ }
770
773
  card.querySelectorAll('.play-action').forEach(btn => btn.addEventListener('click', (e) => { e.stopPropagation(); openZone({ ...game, name: btn.dataset.versionName ? `${game.name} - ${btn.dataset.versionName}` : game.name, url: btn.dataset.url }); }));
771
774
 
772
775
  const versionBtn = card.querySelector('.version-btn');
773
776
  if (versionBtn) { versionBtn.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.eagler-dropdown.show').forEach(d => d.classList.remove('show')); versionBtn.nextElementSibling.classList.toggle('show'); }); }
774
777
 
775
- const offlineVerBtn = card.querySelector('.version-offline-btn');
776
- if (offlineVerBtn) { offlineVerBtn.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.eagler-dropdown.show').forEach(d => d.classList.remove('show')); offlineVerBtn.nextElementSibling.classList.toggle('show'); }); }
777
-
778
- card.querySelectorAll('.offline-link').forEach(link => link.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); toggleOfflineMode(link.dataset.gameId, link.dataset.name, link.dataset.url, offlineVerBtn); link.closest('.eagler-dropdown').classList.remove('show'); }));
779
- card.querySelectorAll('.eagler-dropdown-link:not(.favorite-link):not(.offline-link)').forEach(link => link.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openZone({ ...game, name: link.dataset.versionName ? `${game.name} - ${link.dataset.versionName}` : game.name, url: link.dataset.url }); link.closest('.eagler-dropdown').classList.remove('show'); }));
778
+ card.querySelectorAll('.eagler-dropdown-link:not(.favorite-link)').forEach(link => link.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openZone({ ...game, name: link.dataset.versionName ? `${game.name} - ${link.dataset.versionName}` : game.name, url: link.dataset.url }); link.closest('.eagler-dropdown').classList.remove('show'); }));
780
779
 
781
780
  const favBtn = card.querySelector('.btn-card-action.fav-action');
782
781
  if (favBtn.classList.contains('version-favorite-btn')) {
783
782
  favBtn.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.eagler-dropdown.show').forEach(d => d.classList.remove('show')); favBtn.nextElementSibling.classList.toggle('show'); });
784
783
  card.querySelectorAll('.favorite-link').forEach(link => link.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); toggleFavorite(link.dataset.favoriteId); link.closest('.eagler-dropdown').classList.remove('show'); }));
785
- } else { favBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleFavorite(game.id); }); }
786
-
787
- const offBtn = card.querySelector('.offline-action:not(.version-offline-btn)');
788
- if (offBtn) { offBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleOfflineMode(game.id, game.name, resolveGameUrl(game.url), offBtn); }); }
789
-
784
+ } else {
785
+ favBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleFavorite(game.id); });
786
+ }
790
787
  imageObserver.observe(card.querySelector('img'));
791
788
  return card;
792
789
  }
793
790
 
794
791
  function createGameCard(game, forceSmallCard = false) {
795
792
  if (game.category === 'Others' && !forceSmallCard) return createOthersGameCard(game);
796
- const isStrongdog = game.category === 'StrongdogXP', isGNMath = game.category === 'GN-Math';
793
+ const isStrongdog = game.category === 'StrongdogXP';
794
+
795
+ // Resolve Image Src
797
796
  let imgSrc = game.imgSrc;
798
- if (isStrongdog) imgSrc = getAdjustedUrls(game.imgSrc, game.page).adjustedImgSrc;
799
- else if (game.category === 'Others' || game.category === 'Gameboy Games') imgSrc = resolveGameUrl(game.imgSrc);
797
+ if (isStrongdog) {
798
+ imgSrc = getAdjustedUrls(game.imgSrc, game.page).adjustedImgSrc;
799
+ } else if (game.category === 'Others' || game.category === 'Gameboy Games') {
800
+ imgSrc = resolveGameUrl(game.imgSrc);
801
+ }
800
802
 
801
803
  const isFavorite = getFavorites().map(String).includes(String(game.id));
804
+
802
805
  const card = document.createElement("div");
803
806
  card.className = 'zone-item bg-card-dark rounded-2xl border border-brand-border overflow-hidden';
804
807
  card.dataset.gameId = game.id;
805
-
806
- let gUrl = game.url;
807
- if (game.category === 'Others' || game.category === 'Gameboy Games') gUrl = resolveGameUrl(game.url);
808
- else if (isStrongdog) {
809
- const baseURL = sd_getBaseURLForPage(game.page);
810
- gUrl = baseURL.endsWith("/") ? baseURL + game.href.replace(/^\.\//, "") : baseURL + "/" + game.href.replace(/^\.\//, "");
811
- }
812
-
813
- const offlineBtn = isGNMath ? `<button class="btn-card-action offline-action" data-game-id="${game.id}" data-url="${gUrl}" data-name="${game.name}" title="Enable Offline Mode"><i class="fa-solid fa-cloud"></i></button>` : '';
814
-
815
808
  card.innerHTML = `
816
809
  <div class="relative w-full cursor-pointer group">
817
810
  <div class="aspect-w-3 aspect-h-2"><img data-src="${imgSrc}" alt="${game.name}" class="w-full h-full object-cover"></div>
818
811
  <h3 class="absolute top-2 right-2 z-10 max-w-[80%] bg-black/60 backdrop-blur-md rounded-xl px-3 py-1.5 text-white truncate text-sm font-semibold shadow-lg" title="${game.name}">${game.name}</h3>
819
812
  <div class="absolute bottom-2 right-2 bg-black/50 backdrop-blur-sm rounded-2xl p-1.5 flex items-center gap-1.5 shadow-lg">
820
- <button class="btn-card-action play-action" title="Play Game"><i class="fa-solid fa-play"></i></button>
821
- ${offlineBtn}
813
+ <button class="btn-card-action play-action" title="Play Game"><i class="fa-solid fa-play transition-colors"></i></button>
822
814
  <button class="btn-card-action fav-action ${isFavorite ? 'favorited' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}"><i class="fa-solid fa-star fa-solid-star transition-colors"></i><i class="fa-regular fa-star fa-regular-star transition-colors"></i></button>
823
815
  </div>
824
816
  </div>
@@ -831,121 +823,136 @@
831
823
  let adjustedHref = baseURL.endsWith("/") ? baseURL + game.href.replace(/^\.\//, "") : baseURL + "/" + game.href.replace(/^\.\//, "");
832
824
  const embedPath = await sd_getEmbedPath(adjustedHref, game.href, game.page);
833
825
  openZone({ name: game.name, url: embedPath, category: game.category, id: game.id });
834
- } else { openZone({ ...game, url: gUrl }); }
826
+ } else {
827
+ // For GN-Math, url is absolute (handled in init). For Others/Gameboy, we might need to resolve
828
+ let gUrl = game.url;
829
+ if (game.category === 'Others' || game.category === 'Gameboy Games') {
830
+ gUrl = resolveGameUrl(game.url);
831
+ }
832
+ openZone({ ...game, url: gUrl });
833
+ }
835
834
  };
836
835
 
837
836
  card.querySelector('.group').addEventListener('click', handlePlay);
838
837
  card.querySelector('.play-action').addEventListener('click', handlePlay);
839
838
  card.querySelector('.fav-action').addEventListener('click', (e) => { e.stopPropagation(); toggleFavorite(game.id); });
840
- if (isGNMath) {
841
- const offBtn = card.querySelector('.offline-action');
842
- offBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleOfflineMode(game.id, game.name, gUrl, offBtn); });
843
- }
844
839
  imageObserver.observe(card.querySelector('img'));
845
840
  return card;
846
841
  }
847
842
 
848
843
  // --- Game Viewer Logic ---
849
- async function openZone(game) {
844
+ function openZone(game) {
850
845
  if (!game || !game.url) return;
851
846
  updateURL(categories[currentCategoryIndex], game.id || null);
852
- const zoneViewer = document.getElementById('zoneViewer'), zoneFrame = document.getElementById('zoneFrame'), zoneNameEl = document.getElementById('zoneNameEl'), downloadBtn = document.getElementById('downloadBtnZone'), controls = zoneViewer.querySelector('.zone-controls');
853
-
854
- const show404 = (errorMsg = "Game content unavailable.") => {
855
- const doc = zoneFrame.contentWindow.document;
856
- doc.open();
857
- doc.write(`<!DOCTYPE html><html><head><link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap" rel="stylesheet"><style>body { background: #000; color: #fff; font-family: 'Geist', sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; text-align: center; } h1 { font-size: 4rem; margin: 0; color: #4f46e5; } p { color: #888; font-size: 1.2rem; max-width: 400px; margin-top: 10px; } .btn { margin-top: 30px; padding: 10px 25px; border: 1px solid #333; border-radius: 12px; color: #fff; cursor: pointer; background: #111; transition: all 0.2s; } .btn:hover { border-color: #4f46e5; background: #4f46e51a; }</style></head><body><h1>404</h1><p><strong>${game.name}</strong> could not be loaded.<br><span style="font-size: 0.9rem; opacity: 0.7;">${errorMsg}</span></p><div class="btn" onclick="window.parent.closeZoneViewer()">Return to Hub</div></body></html>`);
858
- doc.close();
859
- zoneViewer.style.display = "flex"; zoneViewer.classList.add('active', 'animate-fade-in');
860
- };
847
+
848
+ const zoneViewer = document.getElementById('zoneViewer');
849
+ const zoneFrame = document.getElementById('zoneFrame');
850
+ const zoneNameEl = document.getElementById('zoneNameEl');
851
+ const downloadBtn = document.getElementById('downloadBtnZone');
852
+ const controls = zoneViewer.querySelector('.zone-controls');
861
853
 
854
+ // Setup Download Button (Specific to Eaglercraft)
862
855
  if (game.baseGameId === 'other-eaglercraft' || game.id === 'other-eaglercraft') {
863
- downloadBtn.href = game.url; downloadBtn.download = game.name.replace(/ /g, '_') + '.html'; downloadBtn.classList.remove('hidden');
864
- } else { downloadBtn.classList.add('hidden'); }
856
+ downloadBtn.href = game.url;
857
+ downloadBtn.download = game.name.replace(/ /g, '_') + '.html';
858
+ downloadBtn.removeAttribute('target');
859
+ downloadBtn.classList.remove('hidden');
860
+ } else {
861
+ downloadBtn.classList.add('hidden');
862
+ downloadBtn.href = '#';
863
+ downloadBtn.removeAttribute('download');
864
+ }
865
865
 
866
+ // --- INJECT FAVORITE BUTTON IN PLAYER HEADER ---
867
+ // Remove existing if any
866
868
  const existingFav = controls.querySelector('.player-fav-btn');
867
869
  if (existingFav) existingFav.remove();
870
+
868
871
  const favBtn = document.createElement('button');
869
872
  favBtn.className = 'player-fav-btn btn-card-action fav-action';
870
873
  favBtn.innerHTML = '<i class="fa-solid fa-star fa-solid-star"></i><i class="fa-regular fa-star fa-regular-star"></i>';
871
- if (getFavorites().map(String).includes(String(game.id))) favBtn.classList.add('favorited');
872
- favBtn.onclick = (e) => { e.stopPropagation(); toggleFavorite(game.id); favBtn.classList.toggle('favorited'); };
874
+ const isFavorited = getFavorites().map(String).includes(String(game.id));
875
+ if (isFavorited) favBtn.classList.add('favorited');
876
+
877
+ favBtn.onclick = (e) => {
878
+ e.stopPropagation();
879
+ toggleFavorite(game.id);
880
+ favBtn.classList.toggle('favorited');
881
+ };
882
+
883
+ // Prepend to controls (Left side)
873
884
  controls.prepend(favBtn);
874
885
 
875
- zoneNameEl.textContent = game.name; zoneFrame.src = 'about:blank';
876
- zoneFrame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-pointer-lock');
886
+ zoneNameEl.textContent = game.name;
887
+
888
+ // RESET FRAME BEFORE LOAD
889
+ zoneFrame.src = 'about:blank';
890
+
891
+ // SECURITY: Set sandbox permissions
892
+ const sandboxRules = 'allow-scripts allow-same-origin allow-forms allow-pointer-lock';
893
+ zoneFrame.setAttribute('sandbox', sandboxRules);
877
894
  zoneFrame.setAttribute('allow', 'fullscreen; pointer-lock; autoplay; clipboard-write');
878
895
 
879
- const offlineGame = await getOfflineGame(game.id);
880
- if (offlineGame) {
881
- const doc = zoneFrame.contentWindow.document; doc.open(); doc.write(offlineGame.html); doc.close();
882
- zoneViewer.style.display = "flex"; zoneViewer.classList.add('active', 'animate-fade-in');
883
- return;
884
- }
885
-
886
- if (!navigator.onLine) { show404("You are offline and this game isn't saved."); return; }
887
-
888
- try {
889
- const check = await fetch(game.url, { method: 'HEAD' });
890
- if (!check.ok) throw new Error("File not found");
896
+ // --- REVISED LOGIC FOR LOADING GAMES ---
897
+ const isStandardURLGame = game.category === 'StrongdogXP' || game.category === 'Others' || game.category === 'GN-Math';
898
+
899
+ if (isStandardURLGame) {
891
900
  zoneFrame.src = game.url;
892
- zoneViewer.style.display = "flex"; zoneViewer.classList.add('active', 'animate-fade-in');
893
- } catch (e) { show404("The game source is broken or blocked."); }
894
- }
895
-
896
- async function toggleOfflineMode(gameId, name, url, buttonElement) {
897
- const isSaved = await isGameOffline(gameId);
898
- try {
899
- if (isSaved) { await removeOfflineGame(gameId); showNotification(`${name} removed from storage.`, 'fa-solid fa-trash', 'info'); }
900
- else { buttonElement.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>'; await saveGameOffline(gameId, name, url); showNotification(`${name} saved!`, 'fa-solid fa-check-circle', 'success'); }
901
- updateAllOfflineButtons();
902
- } catch (e) { showNotification(`Failed to save ${name}.`, 'fa-solid fa-circle-exclamation', 'warning'); }
903
- }
904
-
905
- async function updateAllOfflineButtons() {
906
- const btns = document.querySelectorAll('.offline-action');
907
- for (const btn of btns) {
908
- const isSaved = await isGameOffline(btn.dataset.gameId);
909
- btn.innerHTML = isSaved ? '<i class="fa-solid fa-cloud-arrow-down text-green-400"></i>' : '<i class="fa-solid fa-cloud"></i>';
901
+
902
+ // SHOW WITH ANIMATION
903
+ zoneViewer.style.display = "flex";
904
+ zoneViewer.classList.remove('animate-fade-out');
905
+ zoneViewer.classList.add('active', 'animate-fade-in');
906
+ } else {
907
+ fetch(`${game.url}?t=${Date.now()}`)
908
+ .then(response => {
909
+ if (!response.ok) throw new Error(`HTTP error ${response.status}`);
910
+ return response.text();
911
+ })
912
+ .then(html => {
913
+ const doc = zoneFrame.contentWindow.document;
914
+ doc.open();
915
+ doc.write(html);
916
+ doc.close();
917
+
918
+ // SHOW WITH ANIMATION
919
+ zoneViewer.style.display = "flex";
920
+ zoneViewer.classList.remove('animate-fade-out');
921
+ zoneViewer.classList.add('active', 'animate-fade-in');
922
+ })
923
+ .catch(error => {
924
+ console.error(`Failed to load game "${game.name}": ${error.message}`);
925
+ zoneFrame.src = game.url;
926
+
927
+ zoneViewer.style.display = "flex";
928
+ zoneViewer.classList.remove('animate-fade-out');
929
+ zoneViewer.classList.add('active', 'animate-fade-in');
930
+ });
910
931
  }
911
932
  }
912
933
 
913
934
  function closeZoneViewer() {
914
- const zoneViewer = document.getElementById('zoneViewer'), zoneFrame = document.getElementById('zoneFrame');
915
- zoneViewer.classList.remove('animate-fade-in'); zoneViewer.classList.add('animate-fade-out');
935
+ const zoneViewer = document.getElementById('zoneViewer');
936
+ const zoneFrame = document.getElementById('zoneFrame');
937
+
938
+ zoneViewer.classList.remove('animate-fade-in');
939
+ zoneViewer.classList.add('animate-fade-out');
940
+
916
941
  setTimeout(() => {
917
- zoneViewer.style.display = "none"; zoneViewer.classList.remove('active', 'animate-fade-out');
918
- if (zoneFrame) zoneFrame.src = 'about:blank';
942
+ zoneViewer.style.display = "none";
943
+ zoneViewer.classList.remove('active', 'animate-fade-out');
944
+ if (zoneFrame) {
945
+ zoneFrame.src = 'about:blank';
946
+ }
919
947
  updateURL(categories[currentCategoryIndex]);
948
+ const downloadBtn = document.getElementById('downloadBtnZone');
949
+ downloadBtn.classList.add('hidden');
950
+ downloadBtn.href = '#';
951
+ downloadBtn.removeAttribute('download');
952
+ downloadBtn.removeAttribute('target');
920
953
  }, 300);
921
954
  }
922
955
 
923
- // --- Sound & Notification Logic ---
924
- const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
925
- let isMuted = false;
926
- function playClickSound() {
927
- if (isMuted) return; if (audioCtx.state === 'suspended') audioCtx.resume();
928
- const osc = audioCtx.createOscillator(), gainNode = audioCtx.createGain();
929
- osc.connect(gainNode); gainNode.connect(audioCtx.destination);
930
- osc.type = 'sine'; osc.frequency.setValueAtTime(300, audioCtx.currentTime);
931
- gainNode.gain.setValueAtTime(0.08, audioCtx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.015);
932
- osc.start(); osc.stop(audioCtx.currentTime + 0.015);
933
- }
934
- function showNotification(message, iconClass = 'fa-solid fa-info-circle', type = 'info') {
935
- const container = document.getElementById('notification-container');
936
- if (!container) return;
937
- while (container.children.length >= 3) container.removeChild(container.firstChild);
938
- const toast = document.createElement('div'); toast.className = 'notification-toast';
939
- toast.innerHTML = `<i class="${iconClass} notification-icon ${type}"></i><span>${message}</span>`;
940
- container.appendChild(toast);
941
- requestAnimationFrame(() => { toast.classList.add('show'); playClickSound(); });
942
- setTimeout(() => { toast.classList.remove('show'); setTimeout(() => { if (toast.parentElement) toast.remove(); }, 300); }, 3000);
943
- }
944
-
945
- // Expose to window
946
- window.openZone = openZone; window.closeZoneViewer = closeZoneViewer; window.toggleOfflineMode = toggleOfflineMode;
947
- window.showNotification = showNotification; window.toggleFavorite = toggleFavorite; window.getUserBrowserLocation = async function() {};
948
-
949
956
  // --- Render Lists ---
950
957
  function renderGames(gamesToRender, targetElement, forceSmallCard = false) {
951
958
  targetElement.innerHTML = '';
@@ -957,19 +964,34 @@
957
964
  function renderFavorites() {
958
965
  const favoriteIds = getFavorites().map(String);
959
966
  if (favoriteIds.length > 0) {
960
- favoritesHeader.style.display = "block"; favoritesGameList.style.display = "grid";
967
+ favoritesHeader.style.display = "block";
968
+ favoritesGameList.style.display = "grid";
961
969
  const favoriteGames = [];
962
970
  allGames.forEach(game => {
963
- if (favoriteIds.includes(String(game.id)) && !game.versions) favoriteGames.push(game);
971
+ if (favoriteIds.includes(String(game.id)) && !game.versions) { favoriteGames.push(game); }
964
972
  else if (game.versions) {
965
- game.versions.forEach(v => { if (favoriteIds.includes(String(v.favoriteId))) {
966
- let vUrl = v.url; if (game.category === 'Others' || game.category === 'Gameboy Games') vUrl = resolveGameUrl(v.url);
967
- favoriteGames.push({ ...game, id: v.favoriteId, name: `${game.name} - ${v.name}`, url: vUrl, versions: undefined });
968
- }});
973
+ game.versions.forEach(v => {
974
+ if (favoriteIds.includes(String(v.favoriteId))) {
975
+ let vUrl = v.url;
976
+ if (game.category === 'Others' || game.category === 'Gameboy Games') {
977
+ vUrl = resolveGameUrl(v.url);
978
+ }
979
+ favoriteGames.push({ ...game, id: v.favoriteId, name: `${game.name} - ${v.name}`, url: vUrl, versions: undefined });
980
+ }
981
+ });
969
982
  }
970
983
  });
971
984
  renderGames(favoriteGames.sort((a, b) => a.name.localeCompare(b.name)), favoritesGameList, true);
972
- } else { favoritesHeader.style.display = "none"; favoritesGameList.style.display = "none"; }
985
+ } else {
986
+ favoritesHeader.style.display = "none";
987
+ favoritesGameList.style.display = "none";
988
+ }
989
+ }
990
+
991
+ // --- Category Nav ---
992
+ function updateArrowVisibility() {
993
+ prevCategoryBtn.disabled = currentCategoryIndex === 0;
994
+ nextCategoryBtn.disabled = currentCategoryIndex === categories.length - 1;
973
995
  }
974
996
 
975
997
  function switchCategory(newIndex) {
@@ -988,77 +1010,230 @@
988
1010
  gamesGridContainer.classList.remove('opacity-0');
989
1011
  }
990
1012
 
1013
+ // --- Search ---
1014
+ function debounce(func, delay) {
1015
+ let timeout;
1016
+ return function(...args) {
1017
+ clearTimeout(timeout);
1018
+ timeout = setTimeout(() => func.apply(this, args), delay);
1019
+ };
1020
+ }
1021
+
991
1022
  const handleSearch = debounce(() => {
992
1023
  const text = searchInput.value.toLowerCase().trim();
993
1024
  const filteredDropdown = text ? searchableGames.filter(g => g.name.toLowerCase().includes(text)).slice(0, 5) : [];
1025
+
994
1026
  searchResults.innerHTML = "";
995
- if (filteredDropdown.length === 0 || !text) { searchResults.style.display = "none"; searchDivider.style.display = "none"; return; }
1027
+
1028
+ if (filteredDropdown.length === 0 || !text) {
1029
+ searchResults.style.display = "none";
1030
+ searchDivider.style.display = "none";
1031
+ return;
1032
+ }
1033
+
996
1034
  const favorites = getFavorites().map(String);
997
1035
  filteredDropdown.forEach(game => {
998
1036
  const isStrongdog = game.category === 'StrongdogXP';
999
- let imgSrc, finalGameData = { ...game };
1000
- if (isStrongdog) imgSrc = getAdjustedUrls(game.imgSrc, game.page).adjustedImgSrc;
1001
- else if (game.category === 'Others' || game.category === 'Gameboy Games') { imgSrc = resolveGameUrl(game.imgSrc); finalGameData.url = resolveGameUrl(game.url); }
1002
- else imgSrc = game.imgSrc;
1003
- const linkEl = document.createElement("a"); linkEl.href = '#'; linkEl.className = 'search-item flex justify-between items-center'; linkEl.dataset.gameId = game.id;
1037
+ let imgSrc;
1038
+ let finalGameData = { ...game }; // clone
1039
+
1040
+ if (isStrongdog) {
1041
+ const { adjustedImgSrc } = getAdjustedUrls(game.imgSrc, game.page);
1042
+ imgSrc = adjustedImgSrc;
1043
+ } else if (game.category === 'Others' || game.category === 'Gameboy Games') {
1044
+ imgSrc = resolveGameUrl(game.imgSrc);
1045
+ finalGameData.url = resolveGameUrl(game.url);
1046
+ } else {
1047
+ imgSrc = game.imgSrc;
1048
+ }
1049
+
1050
+ const linkEl = document.createElement("a");
1051
+ linkEl.href = '#';
1052
+ linkEl.className = 'search-item flex justify-between items-center';
1053
+ linkEl.dataset.gameId = game.id;
1004
1054
  let isFavorite = favorites.includes(String(game.id));
1055
+
1005
1056
  const catLabel = game.category === 'StrongdogXP' ? 'StrongdogXP' : (game.category === 'GN-Math' ? 'GN-Math' : 'Others');
1006
1057
  const catColor = game.category === 'StrongdogXP' ? 'text-strongdog-orange' : (game.category === 'GN-Math' ? 'text-gn-math' : 'text-gray-400');
1007
- linkEl.innerHTML = `<div class="flex items-center truncate min-w-0"><img src="${imgSrc}" alt="${game.name}" class="w-10 h-10 rounded-lg object-cover flex-shrink-0"><div class="flex flex-col ml-3 truncate min-w-0"><span class="font-medium truncate text-gray-200" title="${game.name}">${game.name}</span><span class="category-badge w-fit ${catColor}">${catLabel}</span></div></div><div class="flex items-center flex-shrink-0"><div class="bg-black/50 backdrop-blur-sm rounded-2xl p-1.5 flex items-center gap-1.5"><button class="btn-card-action play-action" title="Play Game"><i class="fa-solid fa-play"></i></button><button class="btn-card-action fav-action ${isFavorite ? 'favorited' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}"><i class="fa-solid fa-star fa-solid-star transition-colors"></i><i class="fa-regular fa-star fa-regular-star transition-colors"></i></button></div></div>`;
1008
- linkEl.querySelector('.fav-action').addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); toggleFavorite(game.id); });
1009
- linkEl.addEventListener('click', (e) => { e.preventDefault(); openZone(finalGameData); searchResults.style.display = "none"; searchDivider.style.display = "none"; });
1058
+
1059
+ linkEl.innerHTML = `
1060
+ <div class="flex items-center truncate min-w-0">
1061
+ <img src="${imgSrc}" alt="${game.name}" class="w-10 h-10 rounded-lg object-cover flex-shrink-0">
1062
+ <div class="flex flex-col ml-3 truncate min-w-0">
1063
+ <span class="font-medium truncate text-gray-200" title="${game.name}">${game.name}</span>
1064
+ <span class="category-badge w-fit ${catColor}">${catLabel}</span>
1065
+ </div>
1066
+ </div>
1067
+ <div class="flex items-center flex-shrink-0">
1068
+ <div class="bg-black/50 backdrop-blur-sm rounded-2xl p-1.5 flex items-center gap-1.5">
1069
+ <button class="btn-card-action play-action" title="Play Game">
1070
+ <i class="fa-solid fa-play transition-colors"></i>
1071
+ </button>
1072
+ <button class="btn-card-action fav-action ${isFavorite ? 'favorited' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}">
1073
+ <i class="fa-solid fa-star fa-solid-star transition-colors"></i>
1074
+ <i class="fa-regular fa-star fa-regular-star transition-colors"></i>
1075
+ </button>
1076
+ </div>
1077
+ </div>
1078
+ `;
1079
+
1080
+ const favBtn = linkEl.querySelector('.fav-action');
1081
+ favBtn.addEventListener('click', (e) => {
1082
+ e.preventDefault(); e.stopPropagation();
1083
+ toggleFavorite(game.id);
1084
+ });
1085
+
1086
+ linkEl.addEventListener('click', async (e) => {
1087
+ e.preventDefault();
1088
+ if (isStrongdog) {
1089
+ const baseURL = sd_getBaseURLForPage(game.page);
1090
+ let adjustedHref = baseURL.endsWith("/") ? baseURL + game.href.replace(/^\.\//, "") : baseURL + "/" + game.href.replace(/^\.\//, "");
1091
+ const embedPath = await sd_getEmbedPath(adjustedHref, game.href, game.page);
1092
+ const strongdogGameData = { name: game.name, url: embedPath, category: game.category, id: game.id };
1093
+ openZone(strongdogGameData);
1094
+ } else {
1095
+ openZone(finalGameData);
1096
+ }
1097
+ searchResults.style.display = "none";
1098
+ searchDivider.style.display = "none";
1099
+ });
1010
1100
  searchResults.appendChild(linkEl);
1011
1101
  });
1012
- searchResults.style.display = "block"; searchDivider.style.display = "block";
1102
+ searchResults.style.display = "block";
1103
+ searchDivider.style.display = "block";
1013
1104
  }, 250);
1014
1105
 
1015
- function debounce(func, delay) { let timeout; return function(...args) { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), delay); }; }
1016
-
1106
+ // --- FETCHING DATA MANUALLY (NO MODULES) ---
1107
+ // This function fetches the JS file as text, transforms it to set a global variable, and evals it.
1108
+ // This bypasses module loading restrictions on file:// protocols.
1017
1109
  async function fetchGameData() {
1018
1110
  try {
1019
- const response = await fetch(CARDS_DATA_URL); if (!response.ok) throw new Error('Failed to fetch');
1020
- let scriptText = await response.text(); if (scriptText.includes('export default')) scriptText = scriptText.replace('export default', 'window.loadedGameData =');
1021
- const scriptEl = document.createElement('script'); scriptEl.textContent = scriptText; document.body.appendChild(scriptEl);
1111
+ // Fetch the file
1112
+ const response = await fetch(CARDS_DATA_URL);
1113
+ if (!response.ok) throw new Error('Failed to fetch game data');
1114
+ let scriptText = await response.text();
1115
+
1116
+ // Transform 'export default' to 'window.loadedGameData ='
1117
+ // This assumes the file structure is simple "export default [...]"
1118
+ if (scriptText.includes('export default')) {
1119
+ scriptText = scriptText.replace('export default', 'window.loadedGameData =');
1120
+ }
1121
+
1122
+ // Execute the script
1123
+ // We use new Function instead of eval for slight isolation, though still unsafe for untrusted code
1124
+ // (But we trust this source).
1125
+ // However, new Function creates a local scope. We need global assignment.
1126
+ // Standard eval or appending a script tag is better.
1127
+ const scriptEl = document.createElement('script');
1128
+ scriptEl.textContent = scriptText;
1129
+ document.body.appendChild(scriptEl);
1130
+
1131
+ // Wait a tick for execution
1022
1132
  await new Promise(resolve => setTimeout(resolve, 0));
1023
- return window.loadedGameData || [];
1024
- } catch (e) { console.error(e); return []; }
1133
+
1134
+ if (window.loadedGameData) {
1135
+ return window.loadedGameData;
1136
+ } else {
1137
+ console.warn("Script loaded but window.loadedGameData is undefined");
1138
+ return [];
1139
+ }
1140
+ } catch (e) {
1141
+ console.error("Error manual loading games:", e);
1142
+ return [];
1143
+ }
1025
1144
  }
1026
1145
 
1146
+ // --- Init ---
1027
1147
  async function initializeApp() {
1028
- let gamesData = await fetchGameData();
1148
+ // Load Game Data Manually
1149
+ let gamesData = [];
1150
+ try {
1151
+ gamesData = await fetchGameData();
1152
+ } catch (e) {
1153
+ console.error("Could not load external games data", e);
1154
+ }
1155
+
1029
1156
  const strongdogGames = gamesData.map(g => ({ ...g, category: 'StrongdogXP' }));
1157
+
1030
1158
  try {
1031
- const res = await fetch(GN_ZONES_URL), gnMathRaw = await res.json();
1159
+ const res = await fetch(GN_ZONES_URL);
1160
+ const gnMathRaw = await res.json();
1032
1161
  const gnMathGames = gnMathRaw.map(g => ({ id: `gn-${g.id}`, name: g.name, imgSrc: g.cover.replace("{COVER_URL}", GN_COVER_URL_BASE), category: 'GN-Math', url: g.url.replace("{HTML_URL}", GN_HTML_URL_BASE) }));
1033
1162
  allGames = [...strongdogGames, ...gnMathGames, ...othersGames];
1034
- } catch (error) { allGames = [...strongdogGames, ...othersGames]; }
1163
+ } catch (error) {
1164
+ console.warn("Failed to load GN-Math, falling back to others.", error);
1165
+ allGames = [...strongdogGames, ...othersGames];
1166
+ }
1167
+
1035
1168
  allGames = allGames.filter(g => !['bitlife', 'soundboard'].some(term => g.name.toLowerCase().includes(term)));
1169
+
1170
+ searchableGames = [];
1036
1171
  allGames.forEach(game => {
1037
- if (game.versions && game.versions.length > 0) game.versions.forEach(v => searchableGames.push({ ...game, id: v.favoriteId, name: `${game.name} - ${v.name}`, url: v.url, versions: undefined, baseGameId: game.id }));
1038
- else searchableGames.push(game);
1172
+ if (game.versions && game.versions.length > 0) {
1173
+ game.versions.forEach(version => {
1174
+ searchableGames.push({
1175
+ ...game,
1176
+ id: version.favoriteId,
1177
+ name: `${game.name} - ${version.name}`,
1178
+ url: version.url,
1179
+ wasm: version.wasm,
1180
+ versions: undefined,
1181
+ baseGameId: game.id
1182
+ });
1183
+ });
1184
+ } else {
1185
+ searchableGames.push(game);
1186
+ }
1039
1187
  });
1188
+
1040
1189
  categories = ['StrongdogXP', 'GN-Math', 'Gameboy Games', 'Others'].filter(cat => allGames.some(g => g.category === cat));
1190
+
1041
1191
  const { category: urlCat } = parseURL();
1042
1192
  if (urlCat) currentCategoryIndex = Math.max(0, categories.findIndex(c => c.replace(/\s+/g, '-').toLowerCase() === urlCat.toLowerCase()));
1193
+
1043
1194
  categorySlider.style.width = `${categories.length * 100}%`;
1044
1195
  categorySlider.innerHTML = categories.map(cat => `<div class="w-64 flex-shrink-0"><h2 class="text-3xl font-bold ${categoryColors[cat] || 'text-white'}"><strong>${cat}</strong></h2></div>`).join('');
1045
- switchCategory(currentCategoryIndex); renderFavorites(); updateAllOfflineButtons(); setupInstructionOverlay();
1046
- searchInput.addEventListener("input", handleSearch); searchInput.addEventListener("focus", handleSearch);
1196
+
1197
+ switchCategory(currentCategoryIndex);
1198
+ renderFavorites();
1199
+ setupInstructionOverlay();
1200
+
1201
+ searchInput.placeholder = `Search all games...`;
1202
+ searchInput.addEventListener("input", handleSearch);
1203
+ searchInput.addEventListener("focus", handleSearch);
1204
+
1047
1205
  document.addEventListener("click", ev => {
1048
- if (!document.getElementById('bottom-fixed-bar').contains(ev.target)) { searchResults.style.display = "none"; searchDivider.style.display = "none"; }
1049
- if (!ev.target.closest('.version-btn') && !ev.target.closest('.version-favorite-btn') && !ev.target.closest('.offline-action') && !ev.target.closest('.eagler-dropdown')) document.querySelectorAll('.eagler-dropdown.show').forEach(d => d.classList.remove('show'));
1206
+ if (!document.getElementById('bottom-fixed-bar').contains(ev.target)) {
1207
+ searchResults.style.display = "none";
1208
+ searchDivider.style.display = "none";
1209
+ }
1210
+ if (!ev.target.closest('.version-btn') && !ev.target.closest('.version-favorite-btn') && !ev.target.closest('.eagler-dropdown')) {
1211
+ document.querySelectorAll('.eagler-dropdown.show').forEach(d => {
1212
+ d.classList.remove('show');
1213
+ });
1214
+ }
1050
1215
  });
1216
+
1051
1217
  prevCategoryBtn.addEventListener('click', () => switchCategory(currentCategoryIndex - 1));
1052
1218
  nextCategoryBtn.addEventListener('click', () => switchCategory(currentCategoryIndex + 1));
1053
1219
  document.getElementById('closeBtnZone').addEventListener('click', closeZoneViewer);
1054
- document.getElementById('fullscreenBtnZone').addEventListener('click', () => { const frame = document.getElementById('zoneFrame'); if (frame.requestFullscreen) frame.requestFullscreen(); });
1220
+
1221
+ // --- FULLSCREEN BUTTON LISTENER ---
1222
+ document.getElementById('fullscreenBtnZone').addEventListener('click', () => {
1223
+ const zoneFrame = document.getElementById('zoneFrame');
1224
+ if (zoneFrame && zoneFrame.requestFullscreen) {
1225
+ zoneFrame.requestFullscreen();
1226
+ } else if (zoneFrame && zoneFrame.webkitRequestFullscreen) {
1227
+ zoneFrame.webkitRequestFullscreen();
1228
+ } else if (zoneFrame && zoneFrame.msRequestFullscreen) {
1229
+ zoneFrame.msRequestFullscreen();
1230
+ }
1231
+ });
1232
+
1055
1233
  creditsBtn.addEventListener('click', () => { creditsModal.style.display = 'flex'; creditsContent.innerHTML = categories.map(cat => CREDITS_DATA[cat] ? `<div class="p-4 border border-brand-border rounded-lg"><h4 class="font-bold mb-1">${cat}</h4><p class="text-sm text-gray-400">${CREDITS_DATA[cat].credit}</p></div>` : '').join(''); });
1056
1234
  closeCreditsBtn.addEventListener('click', () => creditsModal.style.display = 'none');
1057
- document.addEventListener('click', (e) => { const target = e.target.closest('button, .nav-tab, .zone-item, .btn-card-action, a, .icon-btn'); if (target) playClickSound(); });
1058
- document.addEventListener('keydown', (e) => { const target = e.target.closest('input, textarea'); if (target && !e.repeat && window.parent?.playTypeSound) window.parent.playTypeSound(); });
1059
- document.addEventListener('copy', () => { if (window.getSelection().toString().length > 0) showNotification('Copied to clipboard', 'fa-solid fa-copy', 'success'); });
1060
- document.addEventListener('paste', (e) => { if (e.target.closest('input, textarea')) showNotification('Pasted from clipboard', 'fa-solid fa-paste', 'info'); });
1061
1235
  }
1236
+
1062
1237
  document.addEventListener('DOMContentLoaded', initializeApp);
1063
1238
  </script>
1064
1239
  </body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "4sp-dv",
3
- "version": "1.0.37",
3
+ "version": "1.0.38",
4
4
  "description": "",
5
5
  "keywords": [],
6
6
  "homepage": "https://github.com/v5-4simpleproblems/v5-4simpleproblems-dv#readme",