A Deno-powered backend service for Plants vs. Zombies: MODDED. [Read-only GitHub mirror] docs.pvzm.net
express typescript expressjs plant deno jspvz pvzm game online backend plants-vs-zombies zombie javascript plants modded vs plantsvszombies openapi pvz noads
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 697 lines 22 kB view raw
1<!doctype html> 2<html lang="en"> 3 <head> 4 <meta charset="UTF-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <link rel="stylesheet" href="css/pico.green.css" /> 7 <title>PVZM Admin Dashboard</title> 8 <style> 9 .hidden { 10 display: none; 11 } 12 .level-actions { 13 display: flex; 14 gap: 0.5rem; 15 } 16 .success-message { 17 color: green; 18 font-weight: bold; 19 } 20 .error-message { 21 color: red; 22 font-weight: bold; 23 } 24 .difficulty-marker { 25 display: inline-block; 26 width: 12px; 27 height: 12px; 28 border-radius: 50%; 29 margin-right: 5px; 30 } 31 /* use tailwind 400 colors */ 32 .difficulty-1 { 33 background-color: oklch(74.6% 0.16 232.661); /* sky */ 34 } 35 .difficulty-2 { 36 background-color: oklch(77.7% 0.152 181.912); /* teal */ 37 } 38 .difficulty-3 { 39 background-color: oklch(79.2% 0.209 151.711); /* green */ 40 } 41 .difficulty-4 { 42 background-color: oklch(84.1% 0.238 128.85); /* lime */ 43 } 44 .difficulty-5 { 45 background-color: oklch(85.2% 0.199 91.936); /* yellow */ 46 } 47 .difficulty-6 { 48 background-color: oklch(82.8% 0.189 84.429); /* amber */ 49 } 50 .difficulty-7 { 51 background-color: oklch(79.2% 0.176 69.885); /* orange */ 52 } 53 .difficulty-8 { 54 background-color: oklch(77.1% 0.21 39.998); /* red */ 55 } 56 .difficulty-9 { 57 background-color: oklch(75.8% 0.182 2.965); /* rose */ 58 } 59 .difficulty-10 { 60 background-color: oklch(74.5% 0.153 325.354); /* pink */ 61 } 62 .pagination { 63 margin-top: 1rem; 64 display: flex; 65 gap: 0.5rem; 66 align-items: center; 67 justify-content: center; 68 } 69 .pagination button { 70 padding: 0.25rem 0.5rem; 71 } 72 .pagination .page-info { 73 margin: 0 0.5rem; 74 } 75 table { 76 width: 100%; 77 } 78 table td { 79 vertical-align: middle; 80 } 81 .stats { 82 display: flex; 83 gap: 1rem; 84 font-size: 0.85rem; 85 } 86 .stats div { 87 display: flex; 88 gap: 0.25rem; 89 align-items: center; 90 } 91 .stats svg { 92 width: 16px; 93 height: 16px; 94 } 95 .user-profile { 96 display: flex; 97 align-items: center; 98 gap: 0.5rem; 99 } 100 .user-avatar { 101 width: 80px; 102 height: 80px; 103 border-radius: 50%; 104 margin-right: 1rem; 105 } 106 div.x-scroll { 107 overflow-x: scroll; 108 } 109 </style> 110 </head> 111 112 <body> 113 <main class="container"> 114 <h1>PVZM Admin Dashboard</h1> 115 <p><a href="/index.html">Back to main page</a></p> 116 117 <!-- Authentication Section --> 118 <article id="auth-section" class="auth-section"> 119 <div id="login-container" class="hidden"> 120 <h2>Admin Authentication Required</h2> 121 <p>Please sign in with GitHub to access the admin dashboard.</p> 122 <a href="/api/auth/github" class="button">Sign in with GitHub</a> 123 </div> 124 <div id="user-profile" class="hidden"> 125 <div class="user-profile"> 126 <img id="user-avatar" class="user-avatar" src="" alt="User avatar" /> 127 <div> 128 <h3 id="user-name">Username</h3> 129 <p>You are signed in as an administrator</p> 130 </div> 131 </div> 132 <a href="/api/auth/logout" class="button">Sign out</a> 133 </div> 134 </article> 135 136 <div id="admin-content" class="hidden"> 137 <article> 138 <h2>Level Management</h2> 139 <fieldset role="group"> 140 <input type="search" id="searchInput" placeholder="Search by name, author, or ID" /> 141 <button id="searchButton">Search</button> 142 <button id="resetButton" class="outline secondary">Reset</button> 143 </fieldset> 144 145 <div id="levelsTableContainer" class="x-scroll"> 146 <table id="levelsTable"> 147 <thead> 148 <tr> 149 <th>ID</th> 150 <th>Name</th> 151 <th>Author</th> 152 <th>Date</th> 153 <th>Stats</th> 154 <th>Difficulty</th> 155 <th>Featured</th> 156 <th>Actions</th> 157 </tr> 158 </thead> 159 <tbody></tbody> 160 </table> 161 </div> 162 163 <div class="pagination"> 164 <button id="prevPage">&laquo; Previous</button> 165 <span class="page-info" id="pageInfo">Page 1 of 1</span> 166 <button id="nextPage">Next &raquo;</button> 167 </div> 168 </article> 169 170 <!-- Edit Modal --> 171 <dialog id="editModal"> 172 <article> 173 <h3>Edit Level</h3> 174 <form id="editForm"> 175 <input type="hidden" id="editLevelId" /> 176 177 <div> 178 <label for="editName">Name</label> 179 <input type="text" id="editName" required /> 180 </div> 181 182 <div> 183 <label for="editAuthor">Author</label> 184 <input type="text" id="editAuthor" required /> 185 </div> 186 187 <div> 188 <label for="editSun">Sun</label> 189 <input type="number" id="editSun" required /> 190 </div> 191 192 <div> 193 <label> 194 <input type="checkbox" id="editIsWater" role="switch" disabled /> 195 Water Level 196 </label> 197 </div> 198 199 <div> 200 <label for="editDifficulty">Difficulty (1-10)</label> 201 <input type="number" id="editDifficulty" min="1" max="10" /> 202 </div> 203 204 <div> 205 <label for="editFavorites">Favorites</label> 206 <input type="number" id="editFavorites" required /> 207 </div> 208 209 <div> 210 <label for="editPlays">Plays</label> 211 <input type="number" id="editPlays" required /> 212 </div> 213 214 <div class="grid"> 215 <button type="button" id="cancelEdit" class="outline">Cancel</button> 216 <button type="submit">Save Changes</button> 217 </div> 218 </form> 219 </article> 220 </dialog> 221 222 <!-- Delete Confirmation Modal --> 223 <dialog id="deleteModal"> 224 <article> 225 <h3>Confirm Deletion</h3> 226 <p>Are you sure you want to delete this level?</p> 227 <p>This action cannot be undone.</p> 228 <footer class="grid"> 229 <button id="cancelDelete" class="outline">Cancel</button> 230 <button id="confirmDelete">Delete</button> 231 </footer> 232 </article> 233 </dialog> 234 </div> 235 </main> 236 <script src="js/pico.modal.js"></script> 237 <script> 238 document.addEventListener("DOMContentLoaded", async () => { 239 const urlParams = new URLSearchParams(window.location.search); 240 const oneTimeToken = (urlParams.get("token") || "").trim(); 241 const tokenActionRaw = (urlParams.get("action") || "").trim(); 242 const tokenAction = tokenActionRaw.toLowerCase(); 243 const tokenLevelId = parseInt((urlParams.get("level") || "").trim(), 10); 244 const hasTokenFlowParams = Boolean(oneTimeToken) && (tokenAction === "edit" || tokenAction === "delete") && Number.isFinite(tokenLevelId); 245 246 function closeWindowAfterTokenAction(levelId) { 247 if (!oneTimeToken) return false; 248 if (String(levelId) !== String(tokenLevelId)) return false; 249 try { 250 window.close(); 251 } catch (_err) { 252 // some browsers block window.close() unless opened by script. 253 } 254 return true; 255 } 256 257 function showAuthedUI(user) { 258 document.getElementById("login-container").classList.add("hidden"); 259 document.getElementById("user-profile").classList.remove("hidden"); 260 document.getElementById("admin-content").classList.remove("hidden"); 261 262 if (user) { 263 document.getElementById("user-name").textContent = user.displayName || user.username || "Admin"; 264 if (user.avatarUrl) { 265 document.getElementById("user-avatar").src = user.avatarUrl; 266 } 267 } 268 } 269 270 function showLoginUI() { 271 document.getElementById("login-container").classList.remove("hidden"); 272 document.getElementById("user-profile").classList.add("hidden"); 273 // token-flow should still be able to open the requested modal. 274 if (hasTokenFlowParams) { 275 document.getElementById("admin-content").classList.remove("hidden"); 276 } else { 277 document.getElementById("admin-content").classList.add("hidden"); 278 } 279 } 280 281 async function validateTokenFlow() { 282 if (!hasTokenFlowParams) return false; 283 try { 284 const resp = await fetch(`${API_BASE_URL}/levels?token=${encodeURIComponent(oneTimeToken)}`); 285 if (!resp.ok) return false; 286 const data = await resp.json(); 287 const levels = Array.isArray(data?.levels) ? data.levels : []; 288 if (levels.length !== 1) return false; 289 return String(levels[0].id) === String(tokenLevelId); 290 } catch (_err) { 291 return false; 292 } 293 } 294 295 async function getTokenLevel() { 296 try { 297 const resp = await fetch(`${API_BASE_URL}/levels?token=${encodeURIComponent(oneTimeToken)}`); 298 if (!resp.ok) return null; 299 const data = await resp.json(); 300 const levels = Array.isArray(data?.levels) ? data.levels : []; 301 if (levels.length !== 1) return null; 302 return levels[0]; 303 } catch (_err) { 304 return null; 305 } 306 } 307 308 // check authentication status 309 try { 310 const authResponse = await fetch("/api/auth/status"); 311 // if github auth is disabled server-side, this route won't exist. 312 if (authResponse.status === 404) { 313 showAuthedUI({ displayName: "Auth disabled" }); 314 } else { 315 const authData = await authResponse.json(); 316 if (authData.authenticated) { 317 showAuthedUI(authData.user); 318 } else { 319 showLoginUI(); 320 } 321 } 322 } catch (error) { 323 console.error("Error checking authentication:", error); 324 // fallback to showing login (if server is down, etc.) 325 showLoginUI(); 326 } 327 328 const API_BASE_URL = "/api"; 329 const PAGE_SIZE = 10; 330 let currentPage = 1; 331 let currentSearchQuery = ""; 332 let totalPages = 1; 333 334 // DOM elements 335 const levelsTable = document.getElementById("levelsTable"); 336 const tbody = levelsTable.querySelector("tbody"); 337 const searchInput = document.getElementById("searchInput"); 338 const searchButton = document.getElementById("searchButton"); 339 const resetButton = document.getElementById("resetButton"); 340 const prevPageButton = document.getElementById("prevPage"); 341 const nextPageButton = document.getElementById("nextPage"); 342 const pageInfo = document.getElementById("pageInfo"); 343 const editModal = document.getElementById("editModal"); 344 const editForm = document.getElementById("editForm"); 345 const cancelEditButton = document.getElementById("cancelEdit"); 346 const deleteModal = document.getElementById("deleteModal"); 347 const confirmDeleteButton = document.getElementById("confirmDelete"); 348 const cancelDeleteButton = document.getElementById("cancelDelete"); 349 350 let levelIdToDelete = null; 351 352 // event listeners 353 searchButton.addEventListener("click", () => { 354 currentSearchQuery = searchInput.value.trim(); 355 currentPage = 1; 356 fetchLevels(); 357 }); 358 359 resetButton.addEventListener("click", () => { 360 searchInput.value = ""; 361 currentSearchQuery = ""; 362 currentPage = 1; 363 fetchLevels(); 364 }); 365 366 prevPageButton.addEventListener("click", () => { 367 if (currentPage > 1) { 368 currentPage--; 369 fetchLevels(); 370 } 371 }); 372 373 nextPageButton.addEventListener("click", () => { 374 if (currentPage < totalPages) { 375 currentPage++; 376 fetchLevels(); 377 } 378 }); 379 380 cancelEditButton.addEventListener("click", () => { 381 closeModal(editModal); 382 }); 383 384 cancelDeleteButton.addEventListener("click", () => { 385 closeModal(deleteModal); 386 levelIdToDelete = null; 387 }); 388 389 confirmDeleteButton.addEventListener("click", async () => { 390 if (levelIdToDelete) { 391 await deleteLevel(levelIdToDelete); 392 closeModal(deleteModal); 393 levelIdToDelete = null; 394 } 395 }); 396 397 editForm.addEventListener("submit", async (e) => { 398 e.preventDefault(); 399 await saveChanges(); 400 closeModal(editModal); 401 }); 402 403 // add event delegation for edit, delete, feature, and unfeature buttons 404 tbody.addEventListener("click", async (e) => { 405 if (e.target.classList.contains("edit-btn")) { 406 const levelId = e.target.getAttribute("data-id"); 407 openEditModal(levelId); 408 } else if (e.target.classList.contains("delete-btn")) { 409 const levelId = e.target.getAttribute("data-id"); 410 openDeleteModal(levelId); 411 } else if (e.target.classList.contains("feature-btn")) { 412 const levelId = e.target.getAttribute("data-id"); 413 await featureLevel(levelId); 414 } else if (e.target.classList.contains("unfeature-btn")) { 415 const levelId = e.target.getAttribute("data-id"); 416 await unfeatureLevel(levelId); 417 } 418 }); 419 420 // init 421 fetchLevels(); 422 423 // token-flow init (open edit/delete modal automatically) 424 if (hasTokenFlowParams) { 425 const ok = await validateTokenFlow(); 426 if (!ok) { 427 alert("Invalid token/action/level parameters."); 428 } else { 429 if (tokenAction === "delete") { 430 openDeleteModal(String(tokenLevelId)); 431 } else if (tokenAction === "edit") { 432 await openEditModal(String(tokenLevelId)); 433 } 434 } 435 } 436 437 // fetch levels from API 438 async function fetchLevels() { 439 // check if user is authenticated before fetching 440 try { 441 const authResponse = await fetch("/api/auth/status"); 442 if (authResponse.status !== 404) { 443 const authData = await authResponse.json(); 444 if (!authData.authenticated) { 445 console.log("User not authenticated, skipping level fetch"); 446 return; 447 } 448 } 449 } catch (error) { 450 console.error("Error checking authentication:", error); 451 return; 452 } 453 454 try { 455 let url = `${API_BASE_URL}/admin/levels?page=${currentPage}&limit=${PAGE_SIZE}`; 456 457 if (currentSearchQuery) { 458 url += `&q=${encodeURIComponent(currentSearchQuery)}`; 459 } 460 461 const response = await fetch(url); 462 if (!response.ok) { 463 throw new Error("Failed to fetch levels"); 464 } 465 466 const data = await response.json(); 467 renderLevelsTable(data.levels); 468 469 // update pagination 470 totalPages = data.totalPages || 1; 471 pageInfo.textContent = `Page ${currentPage} of ${totalPages}`; 472 prevPageButton.disabled = currentPage <= 1; 473 nextPageButton.disabled = currentPage >= totalPages; 474 } catch (error) { 475 console.error("Error fetching levels:", error); 476 alert("Failed to load levels. Please try again later."); 477 } 478 } 479 480 function renderLevelsTable(levels) { 481 tbody.innerHTML = ""; 482 483 if (!levels || levels.length === 0) { 484 const tr = document.createElement("tr"); 485 tr.innerHTML = `<td colspan="8" style="text-align: center;">No levels found</td>`; 486 tbody.appendChild(tr); 487 return; 488 } 489 490 levels.forEach((level) => { 491 const tr = document.createElement("tr"); 492 const date = new Date(level.created_at * 1000); 493 const formattedDate = date.toLocaleDateString(); 494 495 const difficultyClass = level.difficulty ? `difficulty-${level.difficulty}` : ""; 496 const difficultyDisplay = level.difficulty 497 ? `<span class="difficulty-marker ${difficultyClass}"></span>${level.difficulty}/10` 498 : "Not set"; 499 500 const isFeatured = level.featured === 1; 501 const featuredDisplay = isFeatured ? "✓ Yes" : "No"; 502 const featureButton = isFeatured 503 ? `<button class="outline unfeature-btn" data-id="${level.id}">Unfeature</button>` 504 : `<button class="feature-btn" data-id="${level.id}">Feature</button>`; 505 506 tr.innerHTML = ` 507 <td>${level.id}</td> 508 <td>${level.name}</td> 509 <td>${level.author}</td> 510 <td>${formattedDate}</td> 511 <td> 512 <div class="stats"> 513 <div title="Favorites"> 514 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 515 <path d="M7 10v12"></path> 516 <path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2h0a3.13 3.13 0 0 1 3 3.88Z"></path> 517 </svg> 518 ${level.favorites} 519 </div> 520 <div title="Plays"> 521 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 522 <polygon points="5 3 19 12 5 21 5 3"></polygon> 523 </svg> 524 ${level.plays} 525 </div> 526 </div> 527 </td> 528 <td>${difficultyDisplay}</td> 529 <td>${featuredDisplay}</td> 530 <td class="level-actions"> 531 <a role="button" class="outline" href="/api/levels/${level.id}/download" target="_blank" rel="noopener">Download</a> 532 ${featureButton} 533 <button class="edit-btn" data-id="${level.id}">Edit</button> 534 <button class="outline delete-btn" data-id="${level.id}">Delete</button> 535 </td> 536 `; 537 tbody.appendChild(tr); 538 }); 539 } 540 async function openEditModal(levelId) { 541 try { 542 let level; 543 if (oneTimeToken && String(levelId) === String(tokenLevelId)) { 544 level = await getTokenLevel(); 545 if (!level) { 546 throw new Error("Failed to fetch token level details"); 547 } 548 } else { 549 const response = await fetch(`${API_BASE_URL}/levels/${levelId}`); 550 if (!response.ok) { 551 throw new Error("Failed to fetch level details"); 552 } 553 level = await response.json(); 554 } 555 556 // fill the form 557 document.getElementById("editLevelId").value = level.id; 558 document.getElementById("editName").value = level.name; 559 document.getElementById("editAuthor").value = level.author; 560 document.getElementById("editSun").value = level.sun; 561 document.getElementById("editIsWater").checked = level.is_water === 1; 562 document.getElementById("editDifficulty").value = level.difficulty === null || level.difficulty === undefined ? "" : level.difficulty; 563 document.getElementById("editFavorites").value = level.favorites; 564 document.getElementById("editPlays").value = level.plays; 565 566 // show the modal 567 openModal(editModal); 568 } catch (error) { 569 console.error("Error opening edit modal:", error); 570 alert("Failed to load level details."); 571 } 572 } 573 574 async function saveChanges() { 575 try { 576 const levelId = document.getElementById("editLevelId").value; 577 const name = document.getElementById("editName").value.trim(); 578 const author = document.getElementById("editAuthor").value.trim(); 579 const sun = parseInt(document.getElementById("editSun").value); 580 const is_water = document.getElementById("editIsWater").checked ? 1 : 0; 581 const difficultyInput = document.getElementById("editDifficulty").value; 582 const difficulty = difficultyInput ? parseInt(difficultyInput) : null; 583 const favorites = parseInt(document.getElementById("editFavorites").value); 584 const plays = parseInt(document.getElementById("editPlays").value); 585 586 let url = `${API_BASE_URL}/admin/levels/${levelId}`; 587 const usingOneTimeToken = oneTimeToken && String(levelId) === String(tokenLevelId); 588 if (usingOneTimeToken) { 589 url += `?token=${encodeURIComponent(oneTimeToken)}`; 590 } 591 592 const response = await fetch(url, { 593 method: "PUT", 594 headers: { 595 "Content-Type": "application/json", 596 }, 597 body: JSON.stringify({ 598 name, 599 author, 600 sun, 601 is_water, 602 difficulty, 603 favorites, 604 plays, 605 }), 606 }); 607 608 if (!response.ok) { 609 const errorData = await response.json(); 610 throw new Error(errorData.message || "Failed to update level"); 611 } 612 613 if (usingOneTimeToken) { 614 closeWindowAfterTokenAction(levelId); 615 return; 616 } 617 618 fetchLevels(); // refresh the table 619 } catch (error) { 620 console.error("Error saving changes:", error); 621 alert(`Failed to save changes: ${error.message}`); 622 } 623 } 624 625 function openDeleteModal(levelId) { 626 levelIdToDelete = levelId; 627 openModal(deleteModal); 628 } 629 630 async function deleteLevel(levelId) { 631 try { 632 let url = `${API_BASE_URL}/admin/levels/${levelId}`; 633 const usingOneTimeToken = oneTimeToken && String(levelId) === String(tokenLevelId); 634 if (usingOneTimeToken) { 635 url += `?token=${encodeURIComponent(oneTimeToken)}`; 636 } 637 638 const response = await fetch(url, { 639 method: "DELETE", 640 }); 641 642 if (!response.ok) { 643 const errorData = await response.json(); 644 throw new Error(errorData.message || "Failed to delete level"); 645 } 646 647 if (usingOneTimeToken) { 648 closeWindowAfterTokenAction(levelId); 649 return; 650 } 651 652 fetchLevels(); // refresh the table 653 } catch (error) { 654 console.error("Error deleting level:", error); 655 alert(`Failed to delete level: ${error.message}`); 656 } 657 } 658 659 async function featureLevel(levelId) { 660 try { 661 const response = await fetch(`${API_BASE_URL}/admin/levels/${levelId}/feature`, { 662 method: "POST", 663 }); 664 665 if (!response.ok) { 666 const errorData = await response.json(); 667 throw new Error(errorData.message || "Failed to feature level"); 668 } 669 670 fetchLevels(); // refresh the table 671 } catch (error) { 672 console.error("Error featuring level:", error); 673 alert(`Failed to feature level: ${error.message}`); 674 } 675 } 676 677 async function unfeatureLevel(levelId) { 678 try { 679 const response = await fetch(`${API_BASE_URL}/admin/levels/${levelId}/feature`, { 680 method: "DELETE", 681 }); 682 683 if (!response.ok) { 684 const errorData = await response.json(); 685 throw new Error(errorData.message || "Failed to unfeature level"); 686 } 687 688 fetchLevels(); // refresh the table 689 } catch (error) { 690 console.error("Error unfeaturing level:", error); 691 alert(`Failed to unfeature level: ${error.message}`); 692 } 693 } 694 }); 695 </script> 696 </body> 697</html>