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<!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">« Previous</button>
165 <span class="page-info" id="pageInfo">Page 1 of 1</span>
166 <button id="nextPage">Next »</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>