4sp-dv 1.0.32 → 1.0.34

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.31/logged-in/">
9
+ <base href="https://cdn.jsdelivr.net/npm/4sp-dv@1.0.33/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,9 +468,33 @@
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; }
471
491
  </style>
472
492
  </head>
473
493
  <body>
494
+ <div id="offline-indicator">
495
+ <i class="fa-solid fa-plane"></i>
496
+ <span>Offline Mode</span>
497
+ </div>
474
498
  <div id="lock-screen" class="w-full h-full flex flex-col items-center justify-center p-4">
475
499
  <div class="max-w-5xl w-full bg-[#040404] flex flex-col md:flex-row border border-[#252525] rounded-3xl overflow-hidden shadow-2xl">
476
500
  <div class="flex-1 p-8 flex flex-col justify-center items-center border-b md:border-b-0 md:border-r border-[#252525]">
@@ -778,7 +802,7 @@
778
802
  const displayUsername = document.getElementById('display-username');
779
803
  const displayEmail = document.getElementById('display-email');
780
804
 
781
- const BASE_URL = 'https://cdn.jsdelivr.net/npm/4sp-dv@1.0.31/logged-in/';
805
+ const BASE_URL = 'https://cdn.jsdelivr.net/npm/4sp-dv@1.0.33/logged-in/';
782
806
 
783
807
  // Preload Logos
784
808
  const preloadImgs = [
@@ -1189,7 +1213,8 @@
1189
1213
  pfpLetterBg: data.pfpLetterBg,
1190
1214
  letterAvatarText: data.letterAvatarText,
1191
1215
  letterAvatarTextColor: data.letterAvatarTextColor, // New Field
1192
- navbarTheme: data.navbarTheme // Sync theme if updated remotely
1216
+ navbarTheme: data.navbarTheme, // Sync theme if updated remotely
1217
+ verified_online: true // Mark as verified for offline use
1193
1218
  };
1194
1219
 
1195
1220
  localStorage.setItem(USER_DATA_KEY, JSON.stringify(newUserData));
@@ -1280,6 +1305,25 @@
1280
1305
  unlockBtn.classList.add('hidden');
1281
1306
  codeInput.classList.add('hidden');
1282
1307
 
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
+
1283
1327
  try {
1284
1328
  const docRef = doc(db, "access_codes", savedCode);
1285
1329
  const docSnap = await getDoc(docRef);
@@ -1311,7 +1355,17 @@
1311
1355
  }
1312
1356
  } catch(e) {
1313
1357
  console.error("Auto-login error:", e);
1314
- resetLockScreen("Connection failed. Retrying...");
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
+ }
1315
1369
  }
1316
1370
  }
1317
1371
  }
@@ -709,10 +709,84 @@
709
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 } ] },
710
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" } ] },
711
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" } ] },
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" }
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" }
714
713
  ];
715
714
 
715
+ // --- Offline Mode Logic (IndexedDB) ---
716
+ const DB_NAME = '4SP_OfflineGames';
717
+ const DB_VERSION = 1;
718
+ const STORE_NAME = 'games';
719
+
720
+ function openDB() {
721
+ return new Promise((resolve, reject) => {
722
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
723
+ request.onupgradeneeded = (e) => {
724
+ const db = e.target.result;
725
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
726
+ db.createObjectStore(STORE_NAME, { keyPath: 'id' });
727
+ }
728
+ };
729
+ request.onsuccess = (e) => resolve(e.target.result);
730
+ request.onerror = (e) => reject(e.target.error);
731
+ });
732
+ }
733
+
734
+ async function saveGameOffline(gameId, name, url) {
735
+ try {
736
+ const res = await fetch(url);
737
+ if (!res.ok) throw new Error('Fetch failed');
738
+ const html = await res.text();
739
+
740
+ const db = await openDB();
741
+ return new Promise((resolve, reject) => {
742
+ const tx = db.transaction(STORE_NAME, 'readwrite');
743
+ const store = tx.objectStore(STORE_NAME);
744
+ store.put({ id: String(gameId), name, html, timestamp: Date.now() });
745
+ tx.oncomplete = () => {
746
+ db.close();
747
+ resolve();
748
+ };
749
+ tx.onerror = () => reject(tx.error);
750
+ });
751
+ } catch (e) {
752
+ console.error("Offline Save Error:", e);
753
+ throw e;
754
+ }
755
+ }
756
+
757
+ async function getOfflineGame(gameId) {
758
+ const db = await openDB();
759
+ return new Promise((resolve, reject) => {
760
+ const tx = db.transaction(STORE_NAME, 'readonly');
761
+ const store = tx.objectStore(STORE_NAME);
762
+ const request = store.get(String(gameId));
763
+ request.onsuccess = () => {
764
+ db.close();
765
+ resolve(request.result);
766
+ };
767
+ request.onerror = () => reject(request.error);
768
+ });
769
+ }
770
+
771
+ async function removeOfflineGame(gameId) {
772
+ const db = await openDB();
773
+ return new Promise((resolve, reject) => {
774
+ const tx = db.transaction(STORE_NAME, 'readwrite');
775
+ const store = tx.objectStore(STORE_NAME);
776
+ store.delete(String(gameId));
777
+ tx.oncomplete = () => {
778
+ db.close();
779
+ resolve();
780
+ };
781
+ tx.onerror = () => reject(tx.error);
782
+ });
783
+ }
784
+
785
+ async function isGameOffline(gameId) {
786
+ const game = await getOfflineGame(gameId);
787
+ return !!game;
788
+ }
789
+
716
790
  // --- Card Creation ---
717
791
 
718
792
  function createOthersGameCard(game) {
@@ -727,24 +801,30 @@
727
801
 
728
802
  let gameButtonsHtml = '';
729
803
  let favoriteButtonHtml = '';
804
+ let offlineButtonHtml = '';
730
805
 
731
806
  if (game.versions && game.versions.length > 0) {
732
807
  const availableVersions = game.versions;
733
808
  if (availableVersions.length === 1) {
734
809
  const vUrl = resolveGameUrl(availableVersions[0].url);
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>`;
810
+ 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>`;
736
811
  } else {
737
812
  const dropdownLinks = availableVersions.map(v => {
738
813
  const vUrl = resolveGameUrl(v.url);
739
814
  return `<a href="#" class="eagler-dropdown-link" data-url="${vUrl}" data-version-name="${v.name}">${v.name}</a>`
740
815
  }).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>`;
816
+ 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>`;
742
817
  }
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>`;
818
+
819
+ // Offline Toggle for versions
820
+ 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>`;
821
+
822
+ 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>`;
744
823
  } else {
745
824
  const gUrl = resolveGameUrl(game.url);
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>`;
825
+ gameButtonsHtml = `<button class="btn-card-action play-action" data-url="${gUrl}" title="Play Game"><i class="fa-solid fa-play"></i></button>`;
826
+ 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>`;
827
+ 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>`;
748
828
  }
749
829
 
750
830
  card.innerHTML = `
@@ -754,8 +834,9 @@
754
834
  <div class="absolute inset-0 p-4 sm:p-6 flex flex-col justify-between rounded-2xl">
755
835
  <div class="flex items-start justify-between w-full">
756
836
  <h3 class="text-4xl font-bold text-white truncate drop-shadow-lg" style="max-width: 80%;" title="${game.name}">${game.name}</h3>
757
- <div class="flex items-center gap-2 bg-black/80 backdrop-blur-md rounded-2xl p-2">
837
+ <div class="flex items-center gap-1.5 bg-black/50 backdrop-blur-sm rounded-2xl p-1.5">
758
838
  ${gameButtonsHtml}
839
+ ${offlineButtonHtml}
759
840
  ${favoriteButtonHtml}
760
841
  </div>
761
842
  </div>
@@ -775,7 +856,12 @@
775
856
  const versionBtn = card.querySelector('.version-btn');
776
857
  if (versionBtn) { versionBtn.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.eagler-dropdown.show').forEach(d => d.classList.remove('show')); versionBtn.nextElementSibling.classList.toggle('show'); }); }
777
858
 
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'); }));
859
+ const offlineVerBtn = card.querySelector('.version-offline-btn');
860
+ if (offlineVerBtn) { offlineVerBtn.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.eagler-dropdown.show').forEach(d => d.classList.remove('show')); offlineVerBtn.nextElementSibling.classList.toggle('show'); }); }
861
+
862
+ 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'); }));
863
+
864
+ 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'); }));
779
865
 
780
866
  const favBtn = card.querySelector('.btn-card-action.fav-action');
781
867
  if (favBtn.classList.contains('version-favorite-btn')) {
@@ -784,6 +870,12 @@
784
870
  } else {
785
871
  favBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleFavorite(game.id); });
786
872
  }
873
+
874
+ const offBtn = card.querySelector('.offline-action:not(.version-offline-btn)');
875
+ if (offBtn) {
876
+ offBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleOfflineMode(game.id, game.name, resolveGameUrl(game.url), offBtn); });
877
+ }
878
+
787
879
  imageObserver.observe(card.querySelector('img'));
788
880
  return card;
789
881
  }
@@ -805,13 +897,23 @@
805
897
  const card = document.createElement("div");
806
898
  card.className = 'zone-item bg-card-dark rounded-2xl border border-brand-border overflow-hidden';
807
899
  card.dataset.gameId = game.id;
900
+
901
+ let gUrl = game.url;
902
+ if (game.category === 'Others' || game.category === 'Gameboy Games') {
903
+ gUrl = resolveGameUrl(game.url);
904
+ } else if (isStrongdog) {
905
+ const baseURL = sd_getBaseURLForPage(game.page);
906
+ gUrl = baseURL.endsWith("/") ? baseURL + game.href.replace(/^\.\//, "") : baseURL + "/" + game.href.replace(/^\.\//, "");
907
+ }
908
+
808
909
  card.innerHTML = `
809
910
  <div class="relative w-full cursor-pointer group">
810
911
  <div class="aspect-w-3 aspect-h-2"><img data-src="${imgSrc}" alt="${game.name}" class="w-full h-full object-cover"></div>
811
912
  <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>
812
- <div class="absolute bottom-2 right-2 bg-black/80 backdrop-blur-md rounded-2xl p-2 flex items-center gap-2 shadow-lg">
813
- <button class="btn-card-action play-action" title="Play Game"><i class="fa-solid fa-play transition-colors"></i></button>
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>
913
+ <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">
914
+ <button class="btn-card-action play-action" title="Play Game"><i class="fa-solid fa-play"></i></button>
915
+ <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>
916
+ <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"></i><i class="fa-regular fa-star fa-regular-star"></i></button>
815
917
  </div>
816
918
  </div>
817
919
  `;
@@ -824,11 +926,6 @@
824
926
  const embedPath = await sd_getEmbedPath(adjustedHref, game.href, game.page);
825
927
  openZone({ name: game.name, url: embedPath, category: game.category, id: game.id });
826
928
  } 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
929
  openZone({ ...game, url: gUrl });
833
930
  }
834
931
  };
@@ -836,12 +933,16 @@
836
933
  card.querySelector('.group').addEventListener('click', handlePlay);
837
934
  card.querySelector('.play-action').addEventListener('click', handlePlay);
838
935
  card.querySelector('.fav-action').addEventListener('click', (e) => { e.stopPropagation(); toggleFavorite(game.id); });
936
+
937
+ const offBtn = card.querySelector('.offline-action');
938
+ offBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleOfflineMode(game.id, game.name, gUrl, offBtn); });
939
+
839
940
  imageObserver.observe(card.querySelector('img'));
840
941
  return card;
841
942
  }
842
943
 
843
944
  // --- Game Viewer Logic ---
844
- function openZone(game) {
945
+ async function openZone(game) {
845
946
  if (!game || !game.url) return;
846
947
  updateURL(categories[currentCategoryIndex], game.id || null);
847
948
 
@@ -851,20 +952,45 @@
851
952
  const downloadBtn = document.getElementById('downloadBtnZone');
852
953
  const controls = zoneViewer.querySelector('.zone-controls');
853
954
 
854
- // Setup Download Button (Specific to Eaglercraft)
955
+ // --- Custom 404 Page Helper ---
956
+ const show404 = (errorMsg = "Game content unavailable.") => {
957
+ const doc = zoneFrame.contentWindow.document;
958
+ doc.open();
959
+ doc.write(`
960
+ <!DOCTYPE html>
961
+ <html>
962
+ <head>
963
+ <link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap" rel="stylesheet">
964
+ <style>
965
+ 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; }
966
+ h1 { font-size: 4rem; margin: 0; color: #4f46e5; }
967
+ p { color: #888; font-size: 1.2rem; max-width: 400px; margin-top: 10px; }
968
+ .btn { margin-top: 30px; padding: 10px 25px; border: 1px solid #333; border-radius: 12px; color: #fff; cursor: pointer; background: #111; transition: all 0.2s; }
969
+ .btn:hover { border-color: #4f46e5; background: #4f46e51a; }
970
+ </style>
971
+ </head>
972
+ <body>
973
+ <h1>404</h1>
974
+ <p><strong>${game.name}</strong> could not be loaded.<br><span style="font-size: 0.9rem; opacity: 0.7;">${errorMsg}</span></p>
975
+ <div class="btn" onclick="window.parent.closeZoneViewer()">Return to Hub</div>
976
+ </body>
977
+ </html>
978
+ `);
979
+ doc.close();
980
+ zoneViewer.style.display = "flex";
981
+ zoneViewer.classList.add('active', 'animate-fade-in');
982
+ };
983
+
984
+ // Setup Download Button (Only for Eaglercraft)
855
985
  if (game.baseGameId === 'other-eaglercraft' || game.id === 'other-eaglercraft') {
856
986
  downloadBtn.href = game.url;
857
987
  downloadBtn.download = game.name.replace(/ /g, '_') + '.html';
858
- downloadBtn.removeAttribute('target');
859
988
  downloadBtn.classList.remove('hidden');
860
989
  } else {
861
990
  downloadBtn.classList.add('hidden');
862
- downloadBtn.href = '#';
863
- downloadBtn.removeAttribute('download');
864
991
  }
865
992
 
866
993
  // --- INJECT FAVORITE BUTTON IN PLAYER HEADER ---
867
- // Remove existing if any
868
994
  const existingFav = controls.querySelector('.player-fav-btn');
869
995
  if (existingFav) existingFav.remove();
870
996
 
@@ -879,30 +1005,47 @@
879
1005
  toggleFavorite(game.id);
880
1006
  favBtn.classList.toggle('favorited');
881
1007
  };
882
-
883
- // Prepend to controls (Left side)
884
1008
  controls.prepend(favBtn);
885
1009
 
886
1010
  zoneNameEl.textContent = game.name;
887
-
888
- // RESET FRAME BEFORE LOAD
889
1011
  zoneFrame.src = 'about:blank';
890
1012
 
891
- // SECURITY: Set sandbox permissions
892
1013
  const sandboxRules = 'allow-scripts allow-same-origin allow-forms allow-pointer-lock';
893
1014
  zoneFrame.setAttribute('sandbox', sandboxRules);
894
1015
  zoneFrame.setAttribute('allow', 'fullscreen; pointer-lock; autoplay; clipboard-write');
895
1016
 
896
- // --- REVISED LOGIC FOR LOADING GAMES ---
1017
+ // --- CHECK FOR OFFLINE VERSION FIRST ---
1018
+ const offlineGame = await getOfflineGame(game.id);
1019
+ if (offlineGame) {
1020
+ console.log("Loading offline version of", game.name);
1021
+ const doc = zoneFrame.contentWindow.document;
1022
+ doc.open();
1023
+ doc.write(offlineGame.html);
1024
+ doc.close();
1025
+ zoneViewer.style.display = "flex";
1026
+ zoneViewer.classList.add('active', 'animate-fade-in');
1027
+ return;
1028
+ }
1029
+
1030
+ // --- ONLINE LOAD LOGIC ---
1031
+ if (!navigator.onLine) {
1032
+ show404("You are offline and this game isn't saved.");
1033
+ return;
1034
+ }
1035
+
897
1036
  const isStandardURLGame = game.category === 'StrongdogXP' || game.category === 'Others' || game.category === 'GN-Math';
898
1037
 
899
1038
  if (isStandardURLGame) {
900
- zoneFrame.src = game.url;
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');
1039
+ // Try fetching to see if it exists (Fixes the jsdelivr redirect bug)
1040
+ try {
1041
+ const check = await fetch(game.url, { method: 'HEAD' });
1042
+ if (!check.ok) throw new Error("File not found");
1043
+ zoneFrame.src = game.url;
1044
+ zoneViewer.style.display = "flex";
1045
+ zoneViewer.classList.add('active', 'animate-fade-in');
1046
+ } catch (e) {
1047
+ show404("The game source is broken or blocked.");
1048
+ }
906
1049
  } else {
907
1050
  fetch(`${game.url}?t=${Date.now()}`)
908
1051
  .then(response => {
@@ -914,23 +1057,43 @@
914
1057
  doc.open();
915
1058
  doc.write(html);
916
1059
  doc.close();
917
-
918
- // SHOW WITH ANIMATION
919
1060
  zoneViewer.style.display = "flex";
920
- zoneViewer.classList.remove('animate-fade-out');
921
1061
  zoneViewer.classList.add('active', 'animate-fade-in');
922
1062
  })
923
1063
  .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');
1064
+ show404(error.message);
930
1065
  });
931
1066
  }
932
1067
  }
933
1068
 
1069
+ async function toggleOfflineMode(gameId, name, url, buttonElement) {
1070
+ const isSaved = await isGameOffline(gameId);
1071
+ try {
1072
+ if (isSaved) {
1073
+ await removeOfflineGame(gameId);
1074
+ showNotification(`${name} removed from offline storage.`, 'fa-solid fa-trash', 'info');
1075
+ } else {
1076
+ buttonElement.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
1077
+ await saveGameOffline(gameId, name, url);
1078
+ showNotification(`${name} saved for offline play!`, 'fa-solid fa-check-circle', 'success');
1079
+ }
1080
+ updateAllOfflineButtons();
1081
+ } catch (e) {
1082
+ showNotification(`Failed to save ${name}.`, 'fa-solid fa-circle-exclamation', 'warning');
1083
+ }
1084
+ }
1085
+
1086
+ async function updateAllOfflineButtons() {
1087
+ const btns = document.querySelectorAll('.offline-action');
1088
+ for (const btn of btns) {
1089
+ const gameId = btn.dataset.gameId;
1090
+ const isSaved = await isGameOffline(gameId);
1091
+ btn.classList.toggle('favorited', isSaved); // reuse favorited color
1092
+ btn.innerHTML = isSaved ? '<i class="fa-solid fa-cloud-arrow-down text-green-400"></i>' : '<i class="fa-solid fa-cloud"></i>';
1093
+ btn.title = isSaved ? 'Disable Offline Mode' : 'Enable Offline Mode';
1094
+ }
1095
+ }
1096
+
934
1097
  function closeZoneViewer() {
935
1098
  const zoneViewer = document.getElementById('zoneViewer');
936
1099
  const zoneFrame = document.getElementById('zoneFrame');
@@ -1065,7 +1228,7 @@
1065
1228
  </div>
1066
1229
  </div>
1067
1230
  <div class="flex items-center flex-shrink-0">
1068
- <div class="bg-black/80 backdrop-blur-md rounded-2xl p-2 flex items-center gap-2">
1231
+ <div class="bg-black/50 backdrop-blur-sm rounded-2xl p-1.5 flex items-center gap-1.5">
1069
1232
  <button class="btn-card-action play-action" title="Play Game">
1070
1233
  <i class="fa-solid fa-play transition-colors"></i>
1071
1234
  </button>
@@ -1196,6 +1359,7 @@
1196
1359
 
1197
1360
  switchCategory(currentCategoryIndex);
1198
1361
  renderFavorites();
1362
+ updateAllOfflineButtons();
1199
1363
  setupInstructionOverlay();
1200
1364
 
1201
1365
  searchInput.placeholder = `Search all games...`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "4sp-dv",
3
- "version": "1.0.32",
3
+ "version": "1.0.34",
4
4
  "description": "",
5
5
  "keywords": [],
6
6
  "homepage": "https://github.com/v5-4simpleproblems/v5-4simpleproblems-dv#readme",