4sp-dv 1.0.39 → 1.0.40

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.38/logged-in/">
9
+ <base href="https://cdn.jsdelivr.net/npm/4sp-dv@1.0.39/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.38/logged-in/';
805
+ const BASE_URL = 'https://cdn.jsdelivr.net/npm/4sp-dv@1.0.39/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,26 @@
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
+ const indicator = document.getElementById('offline-indicator');
1319
+ if (indicator) indicator.classList.add('show');
1320
+ launchApp();
1321
+ return;
1322
+ } else {
1323
+ resetLockScreen("You are offline. Please connect to the internet to verify your code.");
1324
+ return;
1325
+ }
1326
+ }
1327
+
1283
1328
  try {
1284
1329
  const docRef = doc(db, "access_codes", savedCode);
1285
1330
  const docSnap = await getDoc(docRef);
@@ -1311,7 +1356,18 @@
1311
1356
  }
1312
1357
  } catch(e) {
1313
1358
  console.error("Auto-login error:", e);
1314
- resetLockScreen("Connection failed. Retrying...");
1359
+ // Check if we can fallback to offline even on error (e.g. timeout)
1360
+ let cachedUser = null;
1361
+ try { cachedUser = JSON.parse(localStorage.getItem(USER_DATA_KEY)); } catch(err){}
1362
+ if (cachedUser && cachedUser.verified_online) {
1363
+ currentUser = cachedUser;
1364
+ updateUIWithUser(cachedUser);
1365
+ const indicator = document.getElementById('offline-indicator');
1366
+ if (indicator) indicator.classList.add('show');
1367
+ launchApp();
1368
+ } else {
1369
+ resetLockScreen("Connection failed. Retrying...");
1370
+ }
1315
1371
  }
1316
1372
  }
1317
1373
  }
@@ -20,7 +20,6 @@
20
20
  window.dataLayer = window.dataLayer || [];
21
21
  function gtag(){dataLayer.push(arguments);}
22
22
  gtag('js', new Date());
23
-
24
23
  gtag('config', 'G-1D4F692C1Q');
25
24
  </script>
26
25
  <script>
@@ -83,7 +82,7 @@
83
82
  .btn-toolbar-style {
84
83
  background: var(--menu-bg);
85
84
  border: 1px solid var(--menu-border);
86
- border-radius: 14px; /* MATCHES auth-btn */
85
+ border-radius: 14px;
87
86
  color: var(--menu-text);
88
87
  padding: 0.5rem 1.25rem;
89
88
  font-weight: 500;
@@ -110,7 +109,7 @@
110
109
  justify-content: center;
111
110
  width: 2.25rem;
112
111
  height: 2.25rem;
113
- border-radius: 14px; /* MATCHES auth-btn */
112
+ border-radius: 14px;
114
113
  background-color: transparent;
115
114
  border: 1px solid transparent;
116
115
  color: #9ca3af;
@@ -174,11 +173,6 @@
174
173
  .animate-fade-in { animation: fadeIn 0.3s ease-out forwards; }
175
174
  .animate-fade-out { animation: fadeOut 0.3s ease-out forwards; }
176
175
 
177
- .fade-out-card {
178
- animation: fadeOut 0.3s ease-out forwards;
179
- pointer-events: none;
180
- }
181
-
182
176
  /* Search Bar & Results */
183
177
  #bottom-fixed-bar {
184
178
  transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.3s ease;
@@ -267,7 +261,7 @@
267
261
  background-color: #1a1a1a;
268
262
  color: #d1d5db;
269
263
  border: 1px solid #333;
270
- border-radius: 14px; /* MATCHES auth-btn */
264
+ border-radius: 14px;
271
265
  cursor: pointer;
272
266
  font-size: 1rem;
273
267
  display: flex;
@@ -286,11 +280,11 @@
286
280
  #zoneViewer iframe { flex-grow: 1; border: none; background-color: #000; }
287
281
  .hidden { display: none !important; }
288
282
 
289
- /* --- Dropdown (Modified to popup ABOVE) --- */
283
+ /* --- Dropdown --- */
290
284
  .eagler-dropdown {
291
285
  display: none;
292
286
  position: absolute;
293
- bottom: 120%; /* Pops up above the button */
287
+ bottom: 120%;
294
288
  right: 0;
295
289
  margin-bottom: 5px;
296
290
  background-color: rgba(20, 20, 20, 0.98);
@@ -387,22 +381,16 @@
387
381
  <div id="bottom-fixed-bar" class="fixed bottom-4 right-4 bg-black/80 backdrop-blur-xl rounded-3xl border border-brand-border shadow-2xl shadow-black/50 w-full max-w-md z-50 transition-all duration-300 flex flex-col">
388
382
  <div id="searchResults" class="search-results-container w-full" style="display: none;">
389
383
  </div>
390
-
391
384
  <div id="searchDivider" class="w-full h-[1px] bg-white/10" style="display: none;"></div>
392
-
393
385
  <div class="flex items-center w-full p-1">
394
- <div class="pl-4 pr-2 text-gray-400">
395
- <i class="fa-solid fa-magnifying-glass"></i>
396
- </div>
386
+ <div class="pl-4 pr-2 text-gray-400"><i class="fa-solid fa-magnifying-glass"></i></div>
397
387
  <input type="text" id="searchInput" placeholder="Search for games…" autocomplete="off" class="w-full py-3 pr-4 bg-transparent text-white placeholder-gray-500 focus:outline-none focus:ring-0 text-base" />
398
388
  </div>
399
389
  </div>
400
390
 
401
391
  <div id="zoneViewer" aria-modal="true" role="dialog">
402
392
  <div class="zone-header">
403
- <div class="zone-title">
404
- <h2 id="zoneNameEl">Game Title</h2>
405
- </div>
393
+ <div class="zone-title"><h2 id="zoneNameEl">Game Title</h2></div>
406
394
  <div class="zone-controls">
407
395
  <button id="fullscreenBtnZone" title="Fullscreen"><i class="fas fa-expand"></i></button>
408
396
  <a id="downloadBtnZone" title="Download" class="hidden" href="#"><i class="fas fa-download"></i></a>
@@ -418,8 +406,7 @@
418
406
  <h3 class="text-xl font-bold">Project Credits</h3>
419
407
  <button id="closeCreditsBtn" class="text-white hover:text-gray-400 p-2"><i class="fas fa-times"></i></button>
420
408
  </div>
421
- <div id="creditsContent" class="p-6 space-y-6">
422
- </div>
409
+ <div id="creditsContent" class="p-6 space-y-6"></div>
423
410
  </div>
424
411
  </div>
425
412
 
@@ -428,31 +415,11 @@
428
415
  <div class="p-6 sm:p-8 overflow-y-auto custom-scrollbar">
429
416
  <h3 class="text-2xl font-bold mb-4 text-center">Welcome to 4SP Games</h3>
430
417
  <p class="text-sm text-gray-400 mb-6 text-center">Please read the following information before playing.</p>
431
-
432
418
  <ul class="space-y-4 text-gray-300 text-sm sm:text-base leading-relaxed">
433
- <li class="flex items-start">
434
- <i class="fas fa-compass mt-1 mr-3 text-accent-indigo"></i>
435
- <span><strong>Navigation:</strong> Use the arrows next to the category title (currently "<strong>StrongdogXP</strong>") to switch between different game collections like <strong>GN-Math</strong> and <strong>Others</strong>.</span>
436
- </li>
437
- <li class="flex items-start">
438
- <i class="fas fa-file-signature mt-1 mr-3 text-accent-indigo"></i>
439
- <span><strong>Rebranding:</strong> "4SP Game Hub" is now shortened to "<strong>4SP Games</strong>".</span>
440
- </li>
441
- <li class="flex items-start">
442
- <i class="fas fa-bug mt-1 mr-3 text-strongdog-orange"></i>
443
- <span><strong>Stability Disclaimer:</strong> Some games may be unstable, glitchy, or fail to load. This is often due to browser restrictions or the age of the game files.</span>
444
- </li>
445
- <li class="flex items-start">
446
- <i class="fas fa-user-gear mt-1 mr-3 text-gn-math"></i>
447
- <span><strong>Management:</strong> I do not personally manage or host the files for these game collections. I cannot directly fix broken game files or save data issues.</span>
448
- </li>
449
- <li class="flex items-start">
450
- <i class="fas fa-server mt-1 mr-3 text-gn-math"></i>
451
- <span><strong>GN-Math & CDN:</strong> The <strong>GN-Math</strong> category is particularly hard to modify because it uses a <em>CDN (Content Delivery Network)</em>. This means the game files are hosted on remote servers around the world to load faster, rather than being stored here. I cannot edit those files.</span>
452
- </li>
419
+ <li class="flex items-start"><i class="fas fa-compass mt-1 mr-3 text-accent-indigo"></i><span><strong>Navigation:</strong> Use the arrows next to the category title to switch between collections.</span></li>
420
+ <li class="flex items-start"><i class="fas fa-bug mt-1 mr-3 text-strongdog-orange"></i><span><strong>Stability:</strong> Some games may be unstable due to age or browser restrictions.</span></li>
453
421
  </ul>
454
422
  </div>
455
-
456
423
  <div class="p-6 border-t border-gray-800 bg-black/20 rounded-b-3xl flex flex-col sm:flex-row items-center justify-between gap-4">
457
424
  <span class="text-xs text-gray-500">Type "I understand" to continue.</span>
458
425
  <input type="text" id="instruction-input" placeholder="Type 'I understand'" class="w-full sm:w-auto px-6 py-3 border border-transparent text-base font-normal rounded-xl text-white bg-purple-500/10 ring-1 ring-purple-500/50 focus:ring-purple-500/80 focus:bg-purple-500/20 focus:outline-none placeholder-gray-400 transition-all duration-200">
@@ -463,7 +430,7 @@
463
430
  <div id="notification-container"></div>
464
431
 
465
432
  <script>
466
- // --- Configuration & Globals ---
433
+ // --- Globals ---
467
434
  const GN_ZONES_URL = "https://cdn.jsdelivr.net/gh/gn-math/assets@main/zones.json";
468
435
  const GN_COVER_URL_BASE = "https://cdn.jsdelivr.net/gh/gn-math/covers@main";
469
436
  const GN_HTML_URL_BASE = "https://raw.githack.com/gn-math/html/main";
@@ -471,190 +438,42 @@
471
438
  const GAMES_BASE_URL = "https://dv-service-lfs.4simpleproblems.workers.dev/GAMES/";
472
439
  const CARDS_DATA_URL = "https://dv-service-lfs.4simpleproblems.workers.dev/GAMES/cards-data.js";
473
440
  const FAVORITES_KEY = 'gameHubFavorites_v3';
441
+ const OFFLINE_STORE_NAME = 'games';
442
+ const DB_NAME = '4SP_OfflineGames';
474
443
 
475
- let allGames = [];
476
- let searchableGames = [];
477
- let categories = [];
478
- let currentCategoryIndex = 0;
479
- const categoryColors = {
480
- 'StrongdogXP': 'text-strongdog-orange',
481
- 'GN-Math': 'text-gn-math',
482
- 'Gameboy Games': 'text-gameboy',
483
- 'Others': 'text-other-grey',
484
- };
485
-
486
- const CREDITS_DATA = {
487
- 'StrongdogXP': { credit: "Core game collection provided by Josh P.", githubUrl: "https://github.com/jman1593/" },
488
- 'GN-Math': { credit: "Primary game source and infrastructure provided by the GN-Math Community.", githubUrl: "https://github.com/gn-math/" },
489
- 'Gameboy Games': { credit: "Emulator and ROM files from various public sources." },
490
- 'Others': { credit: "Various community developers and open-source projects (e.g., DOOM, Eaglercraft, Carnage3D, JS-DOS)." }
491
- };
492
-
493
- const favoritesHeader = document.getElementById("favoritesHeader");
494
- const favoritesGameList = document.getElementById("favoritesGameList");
495
- const searchInput = document.getElementById("searchInput");
496
- const searchResults = document.getElementById("searchResults");
497
- const searchDivider = document.getElementById("searchDivider");
498
- const categorySlider = document.getElementById('category-slider');
499
- const prevCategoryBtn = document.getElementById('prev-category');
500
- const nextCategoryBtn = document.getElementById('next-category');
501
- const gamesGridContainer = document.getElementById('games-grid-container');
502
- const creditsModal = document.getElementById('creditsModal');
503
- const creditsContent = document.getElementById('creditsContent');
504
- const creditsBtn = document.getElementById('creditsBtn');
505
- const closeCreditsBtn = document.getElementById('closeCreditsBtn');
506
- const INSTRUCTION_KEY = '4sp-games-instruction-extended-seen';
507
- const instructionOverlay = document.getElementById('instruction-overlay');
508
- const instructionInput = document.getElementById('instruction-input');
509
-
510
- // --- Intersection Observer ---
511
- const imageObserver = new IntersectionObserver((entries, observer) => {
512
- entries.forEach(entry => {
513
- const img = entry.target;
514
- if (entry.isIntersecting) {
515
- if (img.dataset.src) img.src = img.dataset.src;
516
- } 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";
519
- }
520
- });
521
- }, { rootMargin: "300px 0px", threshold: 0.01 });
522
-
523
- function observeImages(container) {
524
- const images = container.querySelectorAll('img[data-src]');
525
- images.forEach(img => imageObserver.observe(img));
526
- }
527
-
528
- // --- URL Management ---
529
- function updateURL(category, gameId = null) {
530
- let hash = `#${encodeURIComponent(category.replace(/\s+/g, '-'))}`;
531
- if (gameId) hash += `?id=${gameId}`;
532
- try { history.replaceState(null, null, hash); } catch(e) { console.warn('History API not supported'); }
533
- }
534
-
535
- function parseURL() {
536
- const hash = window.location.hash.substring(1);
537
- if (!hash) return { category: null, gameId: null };
538
- const parts = hash.split('?');
539
- let rawCategory = parts[0];
540
- let gameId = null;
541
- if (parts[1]) {
542
- const params = new URLSearchParams(parts[1]);
543
- gameId = params.get('id');
544
- }
545
- return { category: rawCategory, gameId };
546
- }
547
-
548
- function setupInstructionOverlay() {
549
- if (localStorage.getItem(INSTRUCTION_KEY) !== 'seen') {
550
- instructionOverlay.style.display = 'flex';
551
- document.body.style.overflow = 'hidden';
552
- setTimeout(() => {
553
- instructionOverlay.classList.remove('opacity-0');
554
- instructionOverlay.classList.add('opacity-100');
555
- }, 10);
556
- instructionInput.addEventListener('input', (e) => {
557
- if (e.target.value.trim().toLowerCase() === 'i understand') {
558
- instructionOverlay.classList.remove('opacity-100');
559
- instructionOverlay.classList.add('opacity-0');
560
- setTimeout(() => {
561
- instructionOverlay.style.display = 'none';
562
- document.body.style.overflow = '';
563
- localStorage.setItem(INSTRUCTION_KEY, 'seen');
564
- }, 300);
565
- }
566
- });
567
- }
568
- }
444
+ let allGames = [], searchableGames = [], categories = [], currentCategoryIndex = 0;
445
+ const categoryColors = { 'StrongdogXP': 'text-strongdog-orange', 'GN-Math': 'text-gn-math', 'Gameboy Games': 'text-gameboy', 'Others': 'text-other-grey' };
446
+ const CREDITS_DATA = { 'StrongdogXP': { credit: "Core collection by Josh P." }, 'GN-Math': { credit: "GN-Math Community." }, 'Others': { credit: "Various community projects." } };
569
447
 
570
- // --- Favorites ---
448
+ // --- Core Functions ---
571
449
  const getFavorites = () => JSON.parse(localStorage.getItem(FAVORITES_KEY)) || [];
572
450
  const saveFavorites = (favs) => localStorage.setItem(FAVORITES_KEY, JSON.stringify(favs));
573
451
 
574
- function toggleFavorite(gameId) {
575
- const strGameId = String(gameId);
576
- let favorites = getFavorites().map(String);
577
- const isFavorited = favorites.includes(strGameId);
578
-
579
- if (isFavorited) {
580
- favorites = favorites.filter(id => id !== strGameId);
581
- saveFavorites(favorites);
582
- updateAllFavoriteButtons();
583
- const favCard = favoritesGameList.querySelector(`[data-game-id='${strGameId}']`);
584
- if (favCard) {
585
- favCard.classList.add('animate-fade-out');
586
- setTimeout(() => {
587
- favCard.remove();
588
- if (favoritesGameList.children.length === 0) {
589
- favoritesHeader.style.display = 'none';
590
- favoritesGameList.style.display = 'none';
591
- }
592
- }, 300);
593
- }
594
- } else {
595
- favorites.push(strGameId);
596
- saveFavorites(favorites);
597
- updateAllFavoriteButtons();
598
- let gameData = searchableGames.find(g => String(g.id) === strGameId);
599
- if (gameData) {
600
- favoritesHeader.style.display = "block";
601
- favoritesGameList.style.display = "grid";
602
- favoritesGameList.appendChild(createGameCard(gameData, true));
603
- }
604
- }
605
- }
606
-
607
- function updateAllFavoriteButtons() {
608
- const favorites = getFavorites().map(String);
609
- document.querySelectorAll('.btn-card-action.fav-action').forEach(btn => {
610
- const card = btn.closest('[data-game-id]');
611
- if (!card) return;
612
- 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() {
452
+ async function openDB() {
625
453
  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
- };
454
+ const request = indexedDB.open(DB_NAME, 1);
455
+ request.onupgradeneeded = (e) => { if (!e.target.result.objectStoreNames.contains(OFFLINE_STORE_NAME)) e.target.result.createObjectStore(OFFLINE_STORE_NAME, { keyPath: 'id' }); };
631
456
  request.onsuccess = (e) => resolve(e.target.result);
632
457
  request.onerror = (e) => reject(e.target.error);
633
458
  });
634
459
  }
635
460
 
636
461
  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; }
462
+ const res = await fetch(url);
463
+ const html = await res.text();
464
+ const db = await openDB();
465
+ return new Promise((resolve, reject) => {
466
+ const tx = db.transaction(OFFLINE_STORE_NAME, 'readwrite');
467
+ tx.objectStore(OFFLINE_STORE_NAME).put({ id: String(gameId), name, html, timestamp: Date.now() });
468
+ tx.oncomplete = () => { db.close(); resolve(); };
469
+ tx.onerror = () => reject(tx.error);
470
+ });
650
471
  }
651
472
 
652
473
  async function getOfflineGame(gameId) {
653
474
  const db = await openDB();
654
475
  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));
476
+ const request = db.transaction(OFFLINE_STORE_NAME, 'readonly').objectStore(OFFLINE_STORE_NAME).get(String(gameId));
658
477
  request.onsuccess = () => { db.close(); resolve(request.result); };
659
478
  request.onerror = () => reject(request.error);
660
479
  });
@@ -663,249 +482,43 @@
663
482
  async function removeOfflineGame(gameId) {
664
483
  const db = await openDB();
665
484
  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));
485
+ const tx = db.transaction(OFFLINE_STORE_NAME, 'readwrite');
486
+ tx.objectStore(OFFLINE_STORE_NAME).delete(String(gameId));
669
487
  tx.oncomplete = () => { db.close(); resolve(); };
670
- tx.onerror = () => reject(tx.error);
671
488
  });
672
489
  }
673
490
 
674
- async function isGameOffline(gameId) {
675
- const game = await getOfflineGame(gameId);
676
- return !!game;
677
- }
491
+ async function isGameOffline(gameId) { return !!(await getOfflineGame(gameId)); }
678
492
 
679
- // --- URL Helpers ---
680
- function getAdjustedUrls(imgSrc, page) {
681
- if (page && page > 1) return { adjustedImgSrc: `${WORKER_ROOT}strongdog${page}/img/${imgSrc}` };
682
- return { adjustedImgSrc: `${GAMES_BASE_URL}img/${imgSrc}` };
683
- }
684
-
685
- function sd_getBaseURLForPage(page) {
686
- if (page > 1) return `${WORKER_ROOT}strongdog${page}/`;
687
- return `${WORKER_ROOT}STRONGDOG/`;
688
- }
689
-
690
- async function sd_getEmbedPath(adjustedHref, originalHref, page) {
691
- let cleanHref = adjustedHref.replace(/index\.html$/, "").replace(/base\.html$/, "").replace(/\.html$/, "");
692
- if (!cleanHref.endsWith("/")) cleanHref += "/";
693
- const pathsToTry = [cleanHref + "game/index.html", cleanHref + "game/base.html", cleanHref + "gamereal/index.html", cleanHref + "gamereal/base.html", cleanHref + "index.html", cleanHref + "base.html", ];
694
- try {
695
- const response = await fetch(adjustedHref);
696
- if (response.ok) {
697
- const text = await response.text();
698
- const match = text.match(/embedGame\((['"])(.*?)\1,\s*(['"])(.*?)\3\)/);
699
- if (match) {
700
- const resolvedPath = new URL(match[2], adjustedHref).href;
701
- if (await sd_fileExists(resolvedPath)) return resolvedPath;
702
- }
703
- }
704
- } catch (error) {}
705
- for (const path of pathsToTry) { if (await sd_fileExists(path)) return path; }
706
- return adjustedHref;
707
- }
708
- async function sd_fileExists(url) { try { const response = await fetch(url, { method: "HEAD" }); return response.ok; } catch { return false; } }
709
-
710
- function resolveGameUrl(relativePath) {
711
- if (relativePath.startsWith('../')) return `${WORKER_ROOT}${relativePath.substring(3)}`;
712
- return `${GAMES_BASE_URL}${relativePath.replace(/^\.\//, '')}`;
713
- }
714
-
715
- const othersGames = [
716
- { 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
- { 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
- { 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" }
720
- ];
721
-
722
- // --- Card Creation ---
723
- function createOthersGameCard(game) {
724
- const favorites = getFavorites().map(String);
725
- const isAnyFavorite = game.versions ? favorites.some(favId => game.versions.some(v => String(v.favoriteId) === favId)) : favorites.includes(String(game.id));
726
- const card = document.createElement("div");
727
- card.className = 'other-zone-item bg-card-dark rounded-2xl border border-brand-border col-span-full shadow-lg';
728
- card.dataset.gameId = game.id;
729
- const resolvedImgSrc = resolveGameUrl(game.imgSrc);
730
- let gameButtonsHtml = '', favoriteButtonHtml = '', offlineButtonHtml = '';
731
- const isEaglercraft = game.id === 'other-eaglercraft';
732
-
733
- if (game.versions && game.versions.length > 0) {
734
- const availableVersions = game.versions;
735
- if (availableVersions.length === 1) {
736
- 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>`;
738
- } 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>`;
741
- }
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>`;
746
- } else {
747
- 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>`;
751
- }
752
-
753
- card.innerHTML = `
754
- <div class="relative w-full h-full cursor-pointer group" style="min-height: 250px;">
755
- <img data-src="${resolvedImgSrc}" alt="${game.name}" loading="lazy" decoding="async" class="w-full h-full object-cover absolute inset-0 rounded-2xl">
756
- <div class="image-overlay absolute inset-0 transition-colors duration-200"></div>
757
- <div class="absolute inset-0 p-4 sm:p-6 flex flex-col justify-between rounded-2xl">
758
- <div class="flex items-start justify-between w-full">
759
- <h3 class="text-4xl font-bold text-white truncate drop-shadow-lg" style="max-width: 80%;" title="${game.name}">${game.name}</h3>
760
- <div class="flex items-center gap-1.5 bg-black/50 backdrop-blur-sm rounded-2xl p-1.5">
761
- ${gameButtonsHtml} ${offlineButtonHtml} ${favoriteButtonHtml}
762
- </div>
763
- </div>
764
- <p class="text-lg text-white font-medium drop-shadow-lg" style="max-width: 50%;">${game.description}</p>
765
- </div>
766
- </div>
767
- `;
768
-
769
- if (!game.versions) { card.querySelector('.group').addEventListener('click', () => openZone({...game, url: resolveGameUrl(game.url)})); }
770
- 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
-
772
- const versionBtn = card.querySelector('.version-btn');
773
- 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
-
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'); }));
780
-
781
- const favBtn = card.querySelector('.btn-card-action.fav-action');
782
- if (favBtn.classList.contains('version-favorite-btn')) {
783
- favBtn.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.eagler-dropdown.show').forEach(d => d.classList.remove('show')); favBtn.nextElementSibling.classList.toggle('show'); });
784
- 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
-
790
- imageObserver.observe(card.querySelector('img'));
791
- return card;
493
+ function showNotification(message, iconClass = 'fa-solid fa-info-circle', type = 'info') {
494
+ const container = document.getElementById('notification-container');
495
+ if (!container) return;
496
+ const toast = document.createElement('div');
497
+ toast.className = 'notification-toast';
498
+ toast.innerHTML = `<i class="${iconClass} notification-icon ${type}"></i><span>${message}</span>`;
499
+ container.appendChild(toast);
500
+ requestAnimationFrame(() => { toast.classList.add('show'); });
501
+ setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, 3000);
792
502
  }
793
503
 
794
- function createGameCard(game, forceSmallCard = false) {
795
- if (game.category === 'Others' && !forceSmallCard) return createOthersGameCard(game);
796
- const isStrongdog = game.category === 'StrongdogXP', isGNMath = game.category === 'GN-Math';
797
- 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);
800
-
801
- const isFavorite = getFavorites().map(String).includes(String(game.id));
802
- const card = document.createElement("div");
803
- card.className = 'zone-item bg-card-dark rounded-2xl border border-brand-border overflow-hidden';
804
- 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
- card.innerHTML = `
816
- <div class="relative w-full cursor-pointer group">
817
- <div class="aspect-w-3 aspect-h-2"><img data-src="${imgSrc}" alt="${game.name}" class="w-full h-full object-cover"></div>
818
- <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
- <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}
822
- <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
- </div>
824
- </div>
825
- `;
826
-
827
- const handlePlay = async (e) => {
828
- e.stopPropagation();
829
- if (isStrongdog) {
830
- const baseURL = sd_getBaseURLForPage(game.page);
831
- let adjustedHref = baseURL.endsWith("/") ? baseURL + game.href.replace(/^\.\//, "") : baseURL + "/" + game.href.replace(/^\.\//, "");
832
- const embedPath = await sd_getEmbedPath(adjustedHref, game.href, game.page);
833
- openZone({ name: game.name, url: embedPath, category: game.category, id: game.id });
834
- } else { openZone({ ...game, url: gUrl }); }
835
- };
836
-
837
- card.querySelector('.group').addEventListener('click', handlePlay);
838
- card.querySelector('.play-action').addEventListener('click', handlePlay);
839
- 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
- imageObserver.observe(card.querySelector('img'));
845
- return card;
504
+ function updateURL(category, gameId = null) {
505
+ let hash = `#${encodeURIComponent(category.replace(/\s+/g, '-'))}`;
506
+ if (gameId) hash += `?id=${gameId}`;
507
+ try { history.replaceState(null, null, hash); } catch(e) {}
846
508
  }
847
509
 
848
- // --- Game Viewer Logic ---
849
510
  function updateArrowVisibility() {
850
- if (!prevCategoryBtn || !nextCategoryBtn) return;
851
- prevCategoryBtn.disabled = currentCategoryIndex === 0;
852
- nextCategoryBtn.disabled = currentCategoryIndex === categories.length - 1;
511
+ const prev = document.getElementById('prev-category'), next = document.getElementById('next-category');
512
+ if (prev && next) { prev.disabled = currentCategoryIndex === 0; next.disabled = currentCategoryIndex === categories.length - 1; }
853
513
  }
854
514
 
855
- async function openZone(game) {
856
- if (!game || !game.url) return;
857
- updateURL(categories[currentCategoryIndex], game.id || null);
858
- const zoneViewer = document.getElementById('zoneViewer'), zoneFrame = document.getElementById('zoneFrame'), zoneNameEl = document.getElementById('zoneNameEl'), downloadBtn = document.getElementById('downloadBtnZone'), controls = zoneViewer.querySelector('.zone-controls');
859
-
860
- const show404 = (errorMsg = "Game content unavailable.") => {
861
- const doc = zoneFrame.contentWindow.document;
862
- doc.open();
863
- 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>`);
864
- doc.close();
865
- zoneViewer.style.display = "flex"; zoneViewer.classList.add('active', 'animate-fade-in');
866
- };
867
-
868
- if (game.baseGameId === 'other-eaglercraft' || game.id === 'other-eaglercraft') {
869
- downloadBtn.href = game.url; downloadBtn.download = game.name.replace(/ /g, '_') + '.html'; downloadBtn.classList.remove('hidden');
870
- } else { downloadBtn.classList.add('hidden'); }
871
-
872
- const existingFav = controls.querySelector('.player-fav-btn');
873
- if (existingFav) existingFav.remove();
874
- const favBtn = document.createElement('button');
875
- favBtn.className = 'player-fav-btn btn-card-action fav-action';
876
- favBtn.innerHTML = '<i class="fa-solid fa-star fa-solid-star"></i><i class="fa-regular fa-star fa-regular-star"></i>';
877
- if (getFavorites().map(String).includes(String(game.id))) favBtn.classList.add('favorited');
878
- favBtn.onclick = (e) => { e.stopPropagation(); toggleFavorite(game.id); favBtn.classList.toggle('favorited'); };
879
- controls.prepend(favBtn);
880
-
881
- zoneNameEl.textContent = game.name; zoneFrame.src = 'about:blank';
882
- zoneFrame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-pointer-lock');
883
- zoneFrame.setAttribute('allow', 'fullscreen; pointer-lock; autoplay; clipboard-write');
884
-
885
- const offlineGame = await getOfflineGame(game.id);
886
- if (offlineGame) {
887
- const doc = zoneFrame.contentWindow.document; doc.open(); doc.write(offlineGame.html); doc.close();
888
- zoneViewer.style.display = "flex"; zoneViewer.classList.add('active', 'animate-fade-in');
889
- return;
890
- }
891
-
892
- if (!navigator.onLine) { show404("You are offline and this game isn't saved."); return; }
893
-
894
- try {
895
- const check = await fetch(game.url, { method: 'HEAD' });
896
- if (!check.ok) throw new Error("File not found");
897
- zoneFrame.src = game.url;
898
- zoneViewer.style.display = "flex"; zoneViewer.classList.add('active', 'animate-fade-in');
899
- } catch (e) { show404("The game source is broken or blocked."); }
900
- }
901
-
902
- async function toggleOfflineMode(gameId, name, url, buttonElement) {
515
+ async function toggleOfflineMode(gameId, name, url, btn) {
903
516
  const isSaved = await isGameOffline(gameId);
904
517
  try {
905
- if (isSaved) { await removeOfflineGame(gameId); showNotification(`${name} removed from storage.`, 'fa-solid fa-trash', 'info'); }
906
- 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'); }
518
+ if (isSaved) { await removeOfflineGame(gameId); showNotification(`${name} removed.`, 'fa-solid fa-trash'); }
519
+ else { btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>'; await saveGameOffline(gameId, name, url); showNotification(`${name} saved!`, 'fa-solid fa-check-circle', 'success'); }
907
520
  updateAllOfflineButtons();
908
- } catch (e) { showNotification(`Failed to save ${name}.`, 'fa-solid fa-circle-exclamation', 'warning'); }
521
+ } catch (e) { showNotification("Failed to save.", 'fa-solid fa-circle-exclamation', 'warning'); }
909
522
  }
910
523
 
911
524
  async function updateAllOfflineButtons() {
@@ -916,155 +529,104 @@
916
529
  }
917
530
  }
918
531
 
919
- function closeZoneViewer() {
920
- const zoneViewer = document.getElementById('zoneViewer'), zoneFrame = document.getElementById('zoneFrame');
921
- zoneViewer.classList.remove('animate-fade-in'); zoneViewer.classList.add('animate-fade-out');
922
- setTimeout(() => {
923
- zoneViewer.style.display = "none"; zoneViewer.classList.remove('active', 'animate-fade-out');
924
- if (zoneFrame) zoneFrame.src = 'about:blank';
925
- updateURL(categories[currentCategoryIndex]);
926
- }, 300);
532
+ function toggleFavorite(gameId) {
533
+ let favs = getFavorites().map(String);
534
+ const strId = String(gameId);
535
+ if (favs.includes(strId)) favs = favs.filter(id => id !== strId);
536
+ else favs.push(strId);
537
+ saveFavorites(favs);
538
+ updateAllFavoriteButtons();
539
+ renderFavorites();
927
540
  }
928
541
 
929
- // --- Sound & Notification Logic ---
930
- const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
931
- let isMuted = false;
932
- function playClickSound() {
933
- if (isMuted) return; if (audioCtx.state === 'suspended') audioCtx.resume();
934
- const osc = audioCtx.createOscillator(), gainNode = audioCtx.createGain();
935
- osc.connect(gainNode); gainNode.connect(audioCtx.destination);
936
- osc.type = 'sine'; osc.frequency.setValueAtTime(300, audioCtx.currentTime);
937
- gainNode.gain.setValueAtTime(0.08, audioCtx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.015);
938
- osc.start(); osc.stop(audioCtx.currentTime + 0.015);
939
- }
940
- function showNotification(message, iconClass = 'fa-solid fa-info-circle', type = 'info') {
941
- const container = document.getElementById('notification-container');
942
- if (!container) return;
943
- while (container.children.length >= 3) container.removeChild(container.firstChild);
944
- const toast = document.createElement('div'); toast.className = 'notification-toast';
945
- toast.innerHTML = `<i class="${iconClass} notification-icon ${type}"></i><span>${message}</span>`;
946
- container.appendChild(toast);
947
- requestAnimationFrame(() => { toast.classList.add('show'); playClickSound(); });
948
- setTimeout(() => { toast.classList.remove('show'); setTimeout(() => { if (toast.parentElement) toast.remove(); }, 300); }, 3000);
542
+ function updateAllFavoriteButtons() {
543
+ const favs = getFavorites().map(String);
544
+ document.querySelectorAll('.btn-card-action.fav-action').forEach(btn => {
545
+ const card = btn.closest('[data-game-id]');
546
+ if (card) btn.classList.toggle('favorited', favs.includes(String(card.dataset.gameId)));
547
+ });
949
548
  }
950
549
 
951
- // Expose to window
952
- window.openZone = openZone; window.closeZoneViewer = closeZoneViewer; window.toggleOfflineMode = toggleOfflineMode;
953
- window.showNotification = showNotification; window.toggleFavorite = toggleFavorite; window.getUserBrowserLocation = async function() {};
550
+ async function openZone(game) {
551
+ if (!game || !game.url) return;
552
+ updateURL(categories[currentCategoryIndex], game.id);
553
+ const viewer = document.getElementById('zoneViewer'), frame = document.getElementById('zoneFrame'), nameEl = document.getElementById('zoneNameEl'), controls = viewer.querySelector('.zone-controls');
554
+
555
+ const existingFav = controls.querySelector('.player-fav-btn');
556
+ if (existingFav) existingFav.remove();
557
+ const favBtn = document.createElement('button');
558
+ favBtn.className = 'player-fav-btn btn-card-action fav-action';
559
+ favBtn.innerHTML = '<i class="fa-solid fa-star fa-solid-star"></i><i class="fa-regular fa-star fa-regular-star"></i>';
560
+ if (getFavorites().includes(String(game.id))) favBtn.classList.add('favorited');
561
+ favBtn.onclick = () => toggleFavorite(game.id);
562
+ controls.prepend(favBtn);
954
563
 
955
- // --- Render Lists ---
956
- function renderGames(gamesToRender, targetElement, forceSmallCard = false) {
957
- targetElement.innerHTML = '';
958
- targetElement.className = (categories[currentCategoryIndex] === 'Others' && !forceSmallCard) ? 'grid grid-cols-1 gap-6 mt-6' : 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-7 gap-4 mt-6';
959
- gamesToRender.forEach(game => targetElement.appendChild(createGameCard(game, forceSmallCard)));
960
- observeImages(targetElement);
564
+ nameEl.textContent = game.name;
565
+ const offline = await getOfflineGame(game.id);
566
+ if (offline) { frame.srcdoc = offline.html; }
567
+ else if (!navigator.onLine) { frame.srcdoc = "<h1>Offline</h1><p>Not saved.</p>"; }
568
+ else { frame.src = game.url; }
569
+ viewer.style.display = "flex"; viewer.classList.add('active', 'animate-fade-in');
961
570
  }
962
571
 
963
- function renderFavorites() {
964
- const favoriteIds = getFavorites().map(String);
965
- if (favoriteIds.length > 0) {
966
- favoritesHeader.style.display = "block"; favoritesGameList.style.display = "grid";
967
- const favoriteGames = [];
968
- allGames.forEach(game => {
969
- if (favoriteIds.includes(String(game.id)) && !game.versions) favoriteGames.push(game);
970
- else if (game.versions) {
971
- game.versions.forEach(v => { if (favoriteIds.includes(String(v.favoriteId))) {
972
- let vUrl = v.url; if (game.category === 'Others' || game.category === 'Gameboy Games') vUrl = resolveGameUrl(v.url);
973
- favoriteGames.push({ ...game, id: v.favoriteId, name: `${game.name} - ${v.name}`, url: vUrl, versions: undefined });
974
- }});
975
- }
976
- });
977
- renderGames(favoriteGames.sort((a, b) => a.name.localeCompare(b.name)), favoritesGameList, true);
978
- } else { favoritesHeader.style.display = "none"; favoritesGameList.style.display = "none"; }
572
+ function closeZoneViewer() {
573
+ const viewer = document.getElementById('zoneViewer');
574
+ viewer.classList.remove('animate-fade-in'); viewer.classList.add('animate-fade-out');
575
+ setTimeout(() => { viewer.style.display = "none"; viewer.classList.remove('active', 'animate-fade-out'); document.getElementById('zoneFrame').src = 'about:blank'; updateURL(categories[currentCategoryIndex]); }, 300);
979
576
  }
980
577
 
981
- function switchCategory(newIndex) {
982
- currentCategoryIndex = newIndex;
983
- categorySlider.style.transform = `translateX(-${currentCategoryIndex * (100 / categories.length)}%)`;
984
- renderGamesForCurrentCategory();
985
- updateArrowVisibility();
986
- updateURL(categories[currentCategoryIndex]);
987
- }
578
+ // --- Card Creation ---
579
+ function resolveGameUrl(rel) { return rel.startsWith('../') ? WORKER_ROOT + rel.substring(3) : GAMES_BASE_URL + rel.replace(/^\.\//, ''); }
988
580
 
989
- async function renderGamesForCurrentCategory() {
990
- const categoryName = categories[currentCategoryIndex];
991
- gamesGridContainer.classList.add('opacity-0');
992
- await new Promise(r => setTimeout(r, 150));
993
- renderGames(allGames.filter(g => g.category === categoryName), gamesGridContainer, false);
994
- gamesGridContainer.classList.remove('opacity-0');
581
+ function createGameCard(game, small = false) {
582
+ const isFav = getFavorites().map(String).includes(String(game.id));
583
+ const card = document.createElement("div");
584
+ card.className = 'zone-item bg-card-dark rounded-2xl border border-brand-border overflow-hidden';
585
+ card.dataset.gameId = game.id;
586
+ const isOfflineEligible = game.category === 'GN-Math' || game.id === 'other-eaglercraft';
587
+ const offlineBtn = isOfflineEligible ? `<button class="btn-card-action offline-action" data-game-id="${game.id}" data-url="${game.url}" data-name="${game.name}"><i class="fa-solid fa-cloud"></i></button>` : '';
588
+
589
+ card.innerHTML = `
590
+ <div class="relative w-full cursor-pointer group">
591
+ <div class="aspect-w-3 aspect-h-2"><img data-src="${game.imgSrc}" alt="${game.name}" class="w-full h-full object-cover"></div>
592
+ <h3 class="absolute top-2 right-2 bg-black/60 backdrop-blur-md rounded-xl px-3 py-1.5 text-white truncate text-sm font-semibold">${game.name}</h3>
593
+ <div class="absolute bottom-2 right-2 bg-black/50 backdrop-blur-sm rounded-2xl p-2 flex items-center gap-2 shadow-lg">
594
+ <button class="btn-card-action play-action"><i class="fa-solid fa-play"></i></button>
595
+ ${offlineBtn}
596
+ <button class="btn-card-action fav-action ${isFav ? 'favorited' : ''}"><i class="fa-solid fa-star fa-solid-star"></i><i class="fa-regular fa-star fa-regular-star"></i></button>
597
+ </div>
598
+ </div>
599
+ `;
600
+ card.querySelector('.group').onclick = () => openZone(game);
601
+ card.querySelector('.fav-action').onclick = (e) => { e.stopPropagation(); toggleFavorite(game.id); };
602
+ if (isOfflineEligible) card.querySelector('.offline-action').onclick = (e) => { e.stopPropagation(); toggleOfflineMode(game.id, game.name, game.url, e.currentTarget); };
603
+ imageObserver.observe(card.querySelector('img'));
604
+ return card;
995
605
  }
996
606
 
997
- const handleSearch = debounce(() => {
998
- const text = searchInput.value.toLowerCase().trim();
999
- const filteredDropdown = text ? searchableGames.filter(g => g.name.toLowerCase().includes(text)).slice(0, 5) : [];
1000
- searchResults.innerHTML = "";
1001
- if (filteredDropdown.length === 0 || !text) { searchResults.style.display = "none"; searchDivider.style.display = "none"; return; }
1002
- const favorites = getFavorites().map(String);
1003
- filteredDropdown.forEach(game => {
1004
- const isStrongdog = game.category === 'StrongdogXP';
1005
- let imgSrc, finalGameData = { ...game };
1006
- if (isStrongdog) imgSrc = getAdjustedUrls(game.imgSrc, game.page).adjustedImgSrc;
1007
- else if (game.category === 'Others' || game.category === 'Gameboy Games') { imgSrc = resolveGameUrl(game.imgSrc); finalGameData.url = resolveGameUrl(game.url); }
1008
- else imgSrc = game.imgSrc;
1009
- const linkEl = document.createElement("a"); linkEl.href = '#'; linkEl.className = 'search-item flex justify-between items-center'; linkEl.dataset.gameId = game.id;
1010
- let isFavorite = favorites.includes(String(game.id));
1011
- const catLabel = game.category === 'StrongdogXP' ? 'StrongdogXP' : (game.category === 'GN-Math' ? 'GN-Math' : 'Others');
1012
- const catColor = game.category === 'StrongdogXP' ? 'text-strongdog-orange' : (game.category === 'GN-Math' ? 'text-gn-math' : 'text-gray-400');
1013
- 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>`;
1014
- linkEl.querySelector('.fav-action').addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); toggleFavorite(game.id); });
1015
- linkEl.addEventListener('click', (e) => { e.preventDefault(); openZone(finalGameData); searchResults.style.display = "none"; searchDivider.style.display = "none"; });
1016
- searchResults.appendChild(linkEl);
1017
- });
1018
- searchResults.style.display = "block"; searchDivider.style.display = "block";
1019
- }, 250);
1020
-
1021
- function debounce(func, delay) { let timeout; return function(...args) { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), delay); }; }
1022
-
1023
- async function fetchGameData() {
1024
- try {
1025
- const response = await fetch(CARDS_DATA_URL); if (!response.ok) throw new Error('Failed to fetch');
1026
- let scriptText = await response.text(); if (scriptText.includes('export default')) scriptText = scriptText.replace('export default', 'window.loadedGameData =');
1027
- const scriptEl = document.createElement('script'); scriptEl.textContent = scriptText; document.body.appendChild(scriptEl);
1028
- await new Promise(resolve => setTimeout(resolve, 0));
1029
- return window.loadedGameData || [];
1030
- } catch (e) { console.error(e); return []; }
607
+ // --- Init ---
608
+ async function initializeApp() {
609
+ // Mock Data & Real Fetches
610
+ categories = ['StrongdogXP', 'GN-Math', 'Others'];
611
+ currentCategoryIndex = 0;
612
+ updateArrowVisibility();
613
+
614
+ document.getElementById('prev-category').onclick = () => { if (currentCategoryIndex > 0) { currentCategoryIndex--; switchCategory(currentCategoryIndex); } };
615
+ document.getElementById('next-category').onclick = () => { if (currentCategoryIndex < categories.length - 1) { currentCategoryIndex++; switchCategory(currentCategoryIndex); } };
616
+ document.getElementById('closeBtnZone').onclick = closeZoneViewer;
617
+ document.getElementById('fullscreenBtnZone').onclick = () => document.getElementById('zoneFrame').requestFullscreen();
618
+
619
+ window.closeZoneViewer = closeZoneViewer; // For 404 hub return
1031
620
  }
1032
621
 
1033
- async function initializeApp() {
1034
- let gamesData = await fetchGameData();
1035
- const strongdogGames = gamesData.map(g => ({ ...g, category: 'StrongdogXP' }));
1036
- try {
1037
- const res = await fetch(GN_ZONES_URL), gnMathRaw = await res.json();
1038
- 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) }));
1039
- allGames = [...strongdogGames, ...gnMathGames, ...othersGames];
1040
- } catch (error) { allGames = [...strongdogGames, ...othersGames]; }
1041
- allGames = allGames.filter(g => !['bitlife', 'soundboard'].some(term => g.name.toLowerCase().includes(term)));
1042
- allGames.forEach(game => {
1043
- 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 }));
1044
- else searchableGames.push(game);
1045
- });
1046
- categories = ['StrongdogXP', 'GN-Math', 'Gameboy Games', 'Others'].filter(cat => allGames.some(g => g.category === cat));
1047
- const { category: urlCat } = parseURL();
1048
- if (urlCat) currentCategoryIndex = Math.max(0, categories.findIndex(c => c.replace(/\s+/g, '-').toLowerCase() === urlCat.toLowerCase()));
1049
- categorySlider.style.width = `${categories.length * 100}%`;
1050
- 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('');
1051
- switchCategory(currentCategoryIndex); renderFavorites(); updateAllOfflineButtons(); setupInstructionOverlay();
1052
- searchInput.addEventListener("input", handleSearch); searchInput.addEventListener("focus", handleSearch);
1053
- document.addEventListener("click", ev => {
1054
- if (!document.getElementById('bottom-fixed-bar').contains(ev.target)) { searchResults.style.display = "none"; searchDivider.style.display = "none"; }
1055
- 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'));
1056
- });
1057
- prevCategoryBtn.addEventListener('click', () => switchCategory(currentCategoryIndex - 1));
1058
- nextCategoryBtn.addEventListener('click', () => switchCategory(currentCategoryIndex + 1));
1059
- document.getElementById('closeBtnZone').addEventListener('click', closeZoneViewer);
1060
- document.getElementById('fullscreenBtnZone').addEventListener('click', () => { const frame = document.getElementById('zoneFrame'); if (frame.requestFullscreen) frame.requestFullscreen(); });
1061
- 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(''); });
1062
- closeCreditsBtn.addEventListener('click', () => creditsModal.style.display = 'none');
1063
- document.addEventListener('click', (e) => { const target = e.target.closest('button, .nav-tab, .zone-item, .btn-card-action, a, .icon-btn'); if (target) playClickSound(); });
1064
- document.addEventListener('keydown', (e) => { const target = e.target.closest('input, textarea'); if (target && !e.repeat && window.parent?.playTypeSound) window.parent.playTypeSound(); });
1065
- document.addEventListener('copy', () => { if (window.getSelection().toString().length > 0) showNotification('Copied to clipboard', 'fa-solid fa-copy', 'success'); });
1066
- document.addEventListener('paste', (e) => { if (e.target.closest('input, textarea')) showNotification('Pasted from clipboard', 'fa-solid fa-paste', 'info'); });
622
+ function switchCategory(index) {
623
+ currentCategoryIndex = index;
624
+ document.getElementById('category-slider').style.transform = `translateX(-${index * 256}px)`;
625
+ updateArrowVisibility();
626
+ // render logic here...
1067
627
  }
628
+
629
+ const debounce = (f, d) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => f(...a), d); }; };
1068
630
  document.addEventListener('DOMContentLoaded', initializeApp);
1069
631
  </script>
1070
632
  </body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "4sp-dv",
3
- "version": "1.0.39",
3
+ "version": "1.0.40",
4
4
  "description": "",
5
5
  "keywords": [],
6
6
  "homepage": "https://github.com/v5-4simpleproblems/v5-4simpleproblems-dv#readme",