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.

featured system

Clay 2650c47d d491835a

+186 -21
+14
modules/db.ts
··· 21 21 favorites: number; 22 22 plays: number; 23 23 version: number; 24 + featured: number; 25 + featured_at: number | null; 24 26 }; 25 27 26 28 function tableHasColumn(db: Database, tableName: string, columnName: string) { ··· 128 130 ).run(); 129 131 } catch (migrationError) { 130 132 console.error("Favorites migration error:", migrationError); 133 + } 134 + 135 + // lightweight runtime migration: featured system 136 + try { 137 + if (!tableHasColumn(db, "levels", "featured")) { 138 + db.prepare("ALTER TABLE levels ADD COLUMN featured INTEGER NOT NULL DEFAULT 0").run(); 139 + } 140 + if (!tableHasColumn(db, "levels", "featured_at")) { 141 + db.prepare("ALTER TABLE levels ADD COLUMN featured_at INTEGER").run(); 142 + } 143 + } catch (migrationError) { 144 + console.error("Featured migration error:", migrationError); 131 145 } 132 146 133 147 function createOneTimeTokenForLevel(levelId: number): string {
+106 -15
modules/routes/admin.ts
··· 34 34 const total = countResult ? (countResult as { count: number }).count : 0; 35 35 36 36 const query = ` 37 - SELECT id, name, author, created_at, sun, is_water, favorites, plays, difficulty, version 37 + SELECT id, name, author, created_at, sun, is_water, favorites, plays, difficulty, version, featured, featured_at 38 38 FROM levels ${whereClause} 39 39 ORDER BY id DESC 40 40 LIMIT ? OFFSET ? ··· 98 98 return res.status(404).json({ error: "Level not found" }); 99 99 } 100 100 101 - const { name, author, sun, is_water, difficulty, favorites, plays } = req.body; 101 + const { name, author, sun, is_water, difficulty, favorites, plays, featured, featured_at } = req.body; 102 + 103 + // Build update query dynamically to only update provided fields 104 + const updates: string[] = []; 105 + const updateParams: any[] = []; 106 + 107 + if (name !== undefined) { 108 + updates.push("name = ?"); 109 + updateParams.push(name); 110 + } 111 + if (author !== undefined) { 112 + updates.push("author = ?"); 113 + updateParams.push(author); 114 + } 115 + if (sun !== undefined) { 116 + updates.push("sun = ?"); 117 + updateParams.push(sun); 118 + } 119 + if (is_water !== undefined) { 120 + updates.push("is_water = ?"); 121 + updateParams.push(is_water); 122 + } 123 + if (difficulty !== undefined) { 124 + updates.push("difficulty = ?"); 125 + updateParams.push(difficulty); 126 + } 127 + if (favorites !== undefined) { 128 + updates.push("favorites = ?"); 129 + updateParams.push(favorites); 130 + } 131 + if (plays !== undefined) { 132 + updates.push("plays = ?"); 133 + updateParams.push(plays); 134 + } 135 + if (featured !== undefined) { 136 + updates.push("featured = ?"); 137 + updateParams.push(featured); 138 + } 139 + if (featured_at !== undefined) { 140 + updates.push("featured_at = ?"); 141 + updateParams.push(featured_at); 142 + } 143 + 144 + if (updates.length === 0) { 145 + return res.status(400).json({ error: "No fields to update" }); 146 + } 147 + 148 + updateParams.push(levelId); 102 149 103 150 dbCtx.db 104 - .prepare(` 105 - UPDATE levels 106 - SET 107 - name = ?, 108 - author = ?, 109 - sun = ?, 110 - is_water = ?, 111 - difficulty = ?, 112 - favorites = ?, 113 - plays = ? 114 - WHERE id = ? 115 - `) 116 - .run(name, author, sun, is_water, difficulty, favorites, plays, levelId); 151 + .prepare(`UPDATE levels SET ${updates.join(", ")} WHERE id = ?`) 152 + .run(...updateParams); 117 153 118 154 const updatedLevel = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId); 119 155 ··· 135 171 console.error("Error updating level:", error); 136 172 res.status(500).json({ 137 173 error: "Failed to update level", 174 + message: (error as Error).message, 175 + }); 176 + } 177 + }); 178 + 179 + // feature a level (admin only) 180 + app.post("/api/admin/levels/:id/feature", deps.ensureAuthenticated, (req: any, res: any) => { 181 + try { 182 + const levelId = parseInt(req.params.id); 183 + 184 + if (!Number.isFinite(levelId) || levelId <= 0) { 185 + return res.status(400).json({ error: "Invalid level ID" }); 186 + } 187 + 188 + const exists = dbCtx.db.prepare("SELECT 1 FROM levels WHERE id = ?").get(levelId); 189 + if (!exists) { 190 + return res.status(404).json({ error: "Level not found" }); 191 + } 192 + 193 + const now = Math.floor(Date.now() / 1000); 194 + dbCtx.db.prepare("UPDATE levels SET featured = 1, featured_at = ? WHERE id = ?").run(now, levelId); 195 + 196 + const updatedLevel = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId); 197 + res.json({ success: true, level: updatedLevel }); 198 + } catch (error) { 199 + console.error("Error featuring level:", error); 200 + res.status(500).json({ 201 + error: "Failed to feature level", 202 + message: (error as Error).message, 203 + }); 204 + } 205 + }); 206 + 207 + // unfeature a level (admin only) 208 + app.delete("/api/admin/levels/:id/feature", deps.ensureAuthenticated, (req: any, res: any) => { 209 + try { 210 + const levelId = parseInt(req.params.id); 211 + 212 + if (!Number.isFinite(levelId) || levelId <= 0) { 213 + return res.status(400).json({ error: "Invalid level ID" }); 214 + } 215 + 216 + const exists = dbCtx.db.prepare("SELECT 1 FROM levels WHERE id = ?").get(levelId); 217 + if (!exists) { 218 + return res.status(404).json({ error: "Level not found" }); 219 + } 220 + 221 + dbCtx.db.prepare("UPDATE levels SET featured = 0, featured_at = NULL WHERE id = ?").run(levelId); 222 + 223 + const updatedLevel = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId); 224 + res.json({ success: true, level: updatedLevel }); 225 + } catch (error) { 226 + console.error("Error unfeaturing level:", error); 227 + res.status(500).json({ 228 + error: "Failed to unfeature level", 138 229 message: (error as Error).message, 139 230 }); 140 231 }
+12 -3
modules/routes/levels.ts
··· 291 291 const sort = String(req.query.sort ?? "").toLowerCase(); 292 292 const reversedOrder = req.query.reversed_order === "true" || req.query.reversed_order === "1"; 293 293 const orderDirection = reversedOrder ? "ASC" : "DESC"; 294 - const orderColumn = sort === "recent" ? "created_at" : sort === "favorites" ? "favorites" : "plays"; 294 + 295 + let orderClause: string; 296 + if (sort === "featured") { 297 + // Featured sort: featured levels first, then by quality score (favorites + plays) and recency 298 + // Quality score: favorites weighted more heavily, plus plays divided by 10 299 + orderClause = `featured DESC, (favorites * 2 + plays / 10.0) DESC, created_at DESC`; 300 + } else { 301 + const orderColumn = sort === "recent" ? "created_at" : sort === "favorites" ? "favorites" : "plays"; 302 + orderClause = `${orderColumn} ${orderDirection}, id ${orderDirection}`; 303 + } 295 304 296 305 const filters: string[] = []; 297 306 const params: (string | number)[] = []; ··· 317 326 params.push(version); 318 327 } 319 328 320 - let query = `SELECT id, name, author, created_at, sun, is_water, favorites, plays, difficulty, version FROM levels`; 329 + let query = `SELECT id, name, author, created_at, sun, is_water, favorites, plays, difficulty, version, featured, featured_at FROM levels`; 321 330 322 331 if (filters.length > 0) { 323 332 query += " WHERE " + filters.join(" AND "); 324 333 } 325 334 326 - query += ` ORDER BY ${orderColumn} ${orderDirection}, id ${orderDirection} LIMIT ? OFFSET ?`; 335 + query += ` ORDER BY ${orderClause} LIMIT ? OFFSET ?`; 327 336 params.push(limit, offset); 328 337 329 338 const levels = dbCtx.db.prepare(query).all(...params);
+54 -3
public/admin.html
··· 152 152 <th>Date</th> 153 153 <th>Stats</th> 154 154 <th>Difficulty</th> 155 + <th>Featured</th> 155 156 <th>Actions</th> 156 157 </tr> 157 158 </thead> ··· 399 400 closeModal(editModal); 400 401 }); 401 402 402 - // add event delegation for edit and delete buttons 403 - tbody.addEventListener("click", (e) => { 403 + // add event delegation for edit, delete, feature, and unfeature buttons 404 + tbody.addEventListener("click", async (e) => { 404 405 if (e.target.classList.contains("edit-btn")) { 405 406 const levelId = e.target.getAttribute("data-id"); 406 407 openEditModal(levelId); 407 408 } else if (e.target.classList.contains("delete-btn")) { 408 409 const levelId = e.target.getAttribute("data-id"); 409 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); 410 417 } 411 418 }); 412 419 ··· 475 482 476 483 if (!levels || levels.length === 0) { 477 484 const tr = document.createElement("tr"); 478 - tr.innerHTML = `<td colspan="7" style="text-align: center;">No levels found</td>`; 485 + tr.innerHTML = `<td colspan="8" style="text-align: center;">No levels found</td>`; 479 486 tbody.appendChild(tr); 480 487 return; 481 488 } ··· 490 497 ? `<span class="difficulty-marker ${difficultyClass}"></span>${level.difficulty}/10` 491 498 : "Not set"; 492 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 + 493 506 tr.innerHTML = ` 494 507 <td>${level.id}</td> 495 508 <td>${level.name}</td> ··· 513 526 </div> 514 527 </td> 515 528 <td>${difficultyDisplay}</td> 529 + <td>${featuredDisplay}</td> 516 530 <td class="level-actions"> 517 531 <a role="button" class="outline" href="/api/levels/${level.id}/download" target="_blank" rel="noopener">Download</a> 532 + ${featureButton} 518 533 <button class="edit-btn" data-id="${level.id}">Edit</button> 519 534 <button class="outline delete-btn" data-id="${level.id}">Delete</button> 520 535 </td> ··· 638 653 } catch (error) { 639 654 console.error("Error deleting level:", error); 640 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}`); 641 692 } 642 693 } 643 694 });