diff --git a/web/pages/admin.edit.mod.php b/web/pages/admin.edit.mod.php index 75e548951..7d7072097 100644 --- a/web/pages/admin.edit.mod.php +++ b/web/pages/admin.edit.mod.php @@ -12,8 +12,6 @@ global $userbank, $theme; -new \Sbpp\View\AdminTabs([], $userbank, $theme); - require_once __DIR__ . '/_admin_edit_helpers.php'; $modId = isset($_GET['id']) ? (int) $_GET['id'] : 0; diff --git a/web/tests/e2e/specs/flows/mod-delete-confirm.spec.ts b/web/tests/e2e/specs/flows/mod-delete-confirm.spec.ts index cacc4b000..2610b8835 100644 --- a/web/tests/e2e/specs/flows/mod-delete-confirm.spec.ts +++ b/web/tests/e2e/specs/flows/mod-delete-confirm.spec.ts @@ -210,7 +210,7 @@ test.describe('flow: admin mod delete confirm modal (#1397 — RemoveMod zombie) // Count badge decrements by exactly one (defensive — the // chrome's `decrementCount` reads the span's text and writes // back `n - 1`). - await expect(countBadge).toHaveText(String(startingCount - 1)); + await expect(countBadge).toHaveText(`(${startingCount - 1})`); // Success toast surfaces. Anchor on `data-kind="success"` plus // a hasText filter on the title our handler emits ("Mod diff --git a/web/tests/integration/ModsDeleteDialogTest.php b/web/tests/integration/ModsDeleteDialogTest.php index 095ac0493..2ccbd38f6 100644 --- a/web/tests/integration/ModsDeleteDialogTest.php +++ b/web/tests/integration/ModsDeleteDialogTest.php @@ -218,18 +218,17 @@ public function testPageTailScriptUsesActionsConstant(): void * The mod count badge must carry `data-testid="mod-count"` so the * page-tail script can decrement it after a delete and the E2E * spec can read it as the pre / post-delete oracle. Without the - * testid the badge is unreachable in a theme-agnostic way (the - * surrounding `

… configured

` shape is too brittle to - * regex against). + * testid the badge is unreachable in a theme-agnostic way. The + * rendered text is `(N)` (same shape as the Admins list badge). */ public function testCountBadgeCarriesTestid(): void { $html = $this->renderModsPage(); $this->assertMatchesRegularExpression( - '/]*data-testid="mod-count"[^>]*>\s*\d+\s*<\/span>/', + '/]*data-testid="mod-count"[^>]*>\s*\(?\d+\)?\s*<\/span>/', $html, - 'The mod count number must be wrapped in .' + 'The mod count number must be wrapped in (optionally parenthesised, matching the Admins list badge).' ); } diff --git a/web/themes/default/css/theme.css b/web/themes/default/css/theme.css index e4ccefca1..5fd064729 100644 --- a/web/themes/default/css/theme.css +++ b/web/themes/default/css/theme.css @@ -2130,6 +2130,11 @@ details.queue-row > summary > .row-actions { .admins-list-card:last-child { border-bottom: none; } .admins-list-card__body { padding: 0.75rem 1rem 0.25rem; } +.mods-list-cards { display: none; } +.mods-list-card { border-bottom: 1px solid var(--border); } +.mods-list-card:last-child { border-bottom: none; } +.mods-list-card__body { padding: 0.75rem 1rem 0.25rem; } + /* ---- Responsive ---- */ [data-mobile-menu] { display: none; } @media (max-width: 1024px) { @@ -2148,6 +2153,7 @@ details.queue-row > summary > .row-actions { dance as `.ban-cards` — hidden at desktop, block at mobile. */ .log-cards { display: block; } .admins-list-cards { display: block; } + .mods-list-cards { display: block; } /* #1181: filter chip rows wrap onto multiple lines on mobile instead of horizontal-scrolling, so every chip is reachable without a swipe. The .scroll-x desktop affordance is the @@ -2159,6 +2165,7 @@ details.queue-row > summary > .row-actions { .ban-cards { display: none; } .log-cards { display: none; } .admins-list-cards { display: none; } + .mods-list-cards { display: none; } } /* ---- Utility classes used by templates ---- */ diff --git a/web/themes/default/page_admin_edit_mod.tpl b/web/themes/default/page_admin_edit_mod.tpl index b8b70cd33..83f08a0ad 100644 --- a/web/themes/default/page_admin_edit_mod.tpl +++ b/web/themes/default/page_admin_edit_mod.tpl @@ -27,7 +27,18 @@ template variable — no MooTools-era `$('enabled').checked = …` re-paint script. *} -
+
+
+ {* nofilter: name is htmlspecialchars'd on store in admin.edit.mod.php; + auto-escaping here would double-encode (#1108 / #1113). *} +

+ Edit mod · {$name nofilter} +

+

+ Update the configuration for this game mod. +

+
+
{csrf_field}
-
-
-

Edit Mod

-

Update the configuration for this game mod.

-
-
-
+
{* nofilter: mod metadata is htmlspecialchars(strip_tags($_POST[…]))'d in admin.edit.mod.php before INSERT/UPDATE, so values pulled back out of `:prefix_mods` are already entity-encoded; auto-escaping the value attribute would double-encode (#1113 audit). The id="icon_hid" element is the channel the popup uploader writes into via window.opener.icon(). *} diff --git a/web/themes/default/page_admin_mods_add.tpl b/web/themes/default/page_admin_mods_add.tpl index beba3bc54..24743e6d1 100644 --- a/web/themes/default/page_admin_mods_add.tpl +++ b/web/themes/default/page_admin_mods_add.tpl @@ -42,7 +42,7 @@ without depending on element ids. The icon-callback hidden input is `#icon_hid` to mirror the edit-mod template. *} -
+
{if NOT $permission_add}
@@ -50,6 +50,15 @@
{else} +
+

+ Add mod +

+

+ Configure a new game mod that can be assigned to bans and servers. +

+
+ {csrf_field}
-
-
-

Add Mod

-

Configure a new game mod that can be assigned to bans and servers.

-
-
-
+
{* #1402: the legacy `` hidden was a vestigial reference to the v1.x ProcessMod() flow; the new submit handler has no equivalent. Replaced with `#icon_hid` so the upload-icon diff --git a/web/themes/default/page_admin_mods_list.tpl b/web/themes/default/page_admin_mods_list.tpl index 290fca42f..273dc1bbe 100644 --- a/web/themes/default/page_admin_mods_list.tpl +++ b/web/themes/default/page_admin_mods_list.tpl @@ -43,15 +43,20 @@
{else} -
-
-
-

Server Mods

-

{$mod_count} configured

-
-
+
+

+ Mods + ({$mod_count}) +

+

+ Game mods that can be assigned to bans and servers. +

+
+ +
{if $mod_count > 0} - +
+
@@ -67,11 +72,11 @@ {foreach from=$mod_list item=mod} {if $permission_editmods || $permission_deletemods} - @@ -126,6 +142,65 @@ {/foreach}
Name
-
+
{$mod.name} @@ -87,12 +92,20 @@ {/if}
-
+
+ {* Icon-only row actions matching banlist / admins list: + Lucide icon + `data-tooltip` + `aria-label` inside + `.row-actions--icons`. Keep `data-testid` / `data-action` + / `data-fallback-href` wiring unchanged. *} +
{if $permission_editmods} - Edit + data-testid="editmod-link" + data-tooltip="Edit" + aria-label="Edit mod {$mod.name|escape}"> + + {/if} {if $permission_deletemods} {* #1397: data-action wires the delete button to the inline @@ -110,14 +123,17 @@ beyond the bug; the fallback is a graceful degradation for the rare case where the JSON dispatcher itself is missing (e.g. third-party theme that stripped api.js). *} - + data-tooltip="Delete" + aria-label="Delete mod {$mod.name|escape}"> + + {/if}
+
+ + {* Mobile cards — paired surface for the global + `@media (max-width: 768px) { .table { display: none } }` + rule. Same display dance as `.admins-list-cards`. *} +
+ {foreach from=$mod_list item=mod} +
+
+ +
+
{$mod.name}
+
+ {$mod.modfolder} + · SU {$mod.steam_universe} +
+
+ {if $mod.enabled} + Enabled + {else} + Disabled + {/if} +
+
+
+ {if $permission_editmods || $permission_deletemods} +
+ {if $permission_editmods} + + + + {/if} + {if $permission_deletemods} + + {/if} +
+ {/if} +
+ {/foreach} +
{else}

No mods configured yet.

@@ -258,10 +333,13 @@ /** * @param {string} mid - * @returns {Element|null} + * @returns {NodeListOf} */ - function rowForMid(mid) { - return document.querySelector('[data-testid="mod-row"][data-id="' + mid + '"]'); + function rowsForMid(mid) { + return document.querySelectorAll( + '[data-testid="mod-row"][data-id="' + mid + '"],' + + '[data-testid="mods-list-card"][data-id="' + mid + '"]' + ); } /** @@ -276,7 +354,7 @@ if (!el) return; var n = Number((el.textContent || '').replace(/[^0-9]/g, '')); if (!Number.isFinite(n) || n <= 0) return; - el.textContent = String(n - 1); + el.textContent = '(' + (n - 1).toLocaleString() + ')'; } /** @returns {HTMLDialogElement|null} */ @@ -397,8 +475,11 @@ toast('error', 'Delete failed', msg); return; } - var row = rowForMid(ctx.mid); - if (row && row.parentNode) row.parentNode.removeChild(row); + var rows = rowsForMid(ctx.mid); + for (var i = 0; i < rows.length; i++) { + var row = rows[i]; + if (row && row.parentNode) row.parentNode.removeChild(row); + } decrementCount(); closeDeleteDialog(); toast('success', 'Mod deleted', ctx.name + ' has been removed.');