Add analyzer and frontend lint guardrails
This commit is contained in:
7
.editorconfig
Normal file
7
.editorconfig
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*.cs]
|
||||||
|
dotnet_diagnostic.CA1707.severity = none
|
||||||
|
dotnet_diagnostic.CA1852.severity = none
|
||||||
|
dotnet_diagnostic.CA1825.severity = none
|
||||||
|
dotnet_diagnostic.CA1861.severity = none
|
||||||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@@ -14,6 +14,20 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install frontend tooling
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Lint frontend
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Check frontend formatting
|
||||||
|
run: npm run format:check
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@ artifacts/
|
|||||||
# IDE
|
# IDE
|
||||||
.vs/
|
.vs/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
# User secrets / configs
|
# User secrets / configs
|
||||||
appsettings.Development.json
|
appsettings.Development.json
|
||||||
|
|||||||
7
Directory.Build.props
Normal file
7
Directory.Build.props
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<EnableNETAnalyzers>true</EnableNETAnalyzers>
|
||||||
|
<AnalysisLevel>latest-recommended</AnalysisLevel>
|
||||||
|
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -133,7 +133,12 @@ internal static class EndpointHelpers
|
|||||||
return false;
|
return false;
|
||||||
|
|
||||||
var path = uri.AbsolutePath.ToLowerInvariant();
|
var path = uri.AbsolutePath.ToLowerInvariant();
|
||||||
return path.EndsWith(".png") || path.EndsWith(".jpg") || path.EndsWith(".jpeg") || path.EndsWith(".gif") || path.EndsWith(".webp") || path.EndsWith(".avif");
|
return path.EndsWith(".png", StringComparison.Ordinal)
|
||||||
|
|| path.EndsWith(".jpg", StringComparison.Ordinal)
|
||||||
|
|| path.EndsWith(".jpeg", StringComparison.Ordinal)
|
||||||
|
|| path.EndsWith(".gif", StringComparison.Ordinal)
|
||||||
|
|| path.EndsWith(".webp", StringComparison.Ordinal)
|
||||||
|
|| path.EndsWith(".avif", StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<bool> IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, HttpMessageHandler? handler = null, CancellationToken ct = default)
|
public static async Task<bool> IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, HttpMessageHandler? handler = null, CancellationToken ct = default)
|
||||||
@@ -193,7 +198,7 @@ internal static class EndpointHelpers
|
|||||||
|
|
||||||
await using var stream = await resp.Content.ReadAsStreamAsync(cts.Token);
|
await using var stream = await resp.Content.ReadAsStreamAsync(cts.Token);
|
||||||
var rented = new byte[12];
|
var rented = new byte[12];
|
||||||
var read = await stream.ReadAsync(rented, 0, rented.Length, cts.Token);
|
var read = await stream.ReadAsync(rented.AsMemory(0, rented.Length), cts.Token);
|
||||||
var sig = new ReadOnlySpan<byte>(rented, 0, read);
|
var sig = new ReadOnlySpan<byte>(rented, 0, read);
|
||||||
|
|
||||||
if (IsMagic(sig, "PNG"))
|
if (IsMagic(sig, "PNG"))
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ public static class PlayerIdentityExtensions
|
|||||||
public const string PlayerCookieName = "player";
|
public const string PlayerCookieName = "player";
|
||||||
public const string AdminClaim = "is_admin";
|
public const string AdminClaim = "is_admin";
|
||||||
public const string AdminPolicy = "AdminOnly";
|
public const string AdminPolicy = "AdminOnly";
|
||||||
|
private static readonly Action<ILogger, Exception?> LogUnhandledException =
|
||||||
|
LoggerMessage.Define(
|
||||||
|
LogLevel.Error,
|
||||||
|
new EventId(1001, nameof(LogUnhandledException)),
|
||||||
|
"Unhandled exception");
|
||||||
|
|
||||||
public static async Task SignInPlayerAsync(HttpContext ctx, Player player)
|
public static async Task SignInPlayerAsync(HttpContext ctx, Player player)
|
||||||
{
|
{
|
||||||
@@ -40,7 +45,7 @@ public static class PlayerIdentityExtensions
|
|||||||
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("GlobalException");
|
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("GlobalException");
|
||||||
if (feature?.Error != null)
|
if (feature?.Error != null)
|
||||||
{
|
{
|
||||||
logger.LogError(feature.Error, "Unhandled exception");
|
LogUnhandledException(logger, feature.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
|
|||||||
4. Open:
|
4. Open:
|
||||||
`http://localhost:5000` (or the URL shown by `dotnet run`)
|
`http://localhost:5000` (or the URL shown by `dotnet run`)
|
||||||
|
|
||||||
|
## Frontend Tooling
|
||||||
|
|
||||||
|
- Install tooling: `npm install`
|
||||||
|
- Lint JS: `npm run lint`
|
||||||
|
- Check formatting: `npm run format:check`
|
||||||
|
- Apply formatting: `npm run format`
|
||||||
|
|
||||||
## Core Behavior
|
## Core Behavior
|
||||||
|
|
||||||
- Authentication: username/password with HttpOnly `player` cookie.
|
- Authentication: username/password with HttpOnly `player` cookie.
|
||||||
@@ -44,5 +51,6 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
|
|||||||
GitHub Actions workflow: `.github/workflows/ci.yml`
|
GitHub Actions workflow: `.github/workflows/ci.yml`
|
||||||
|
|
||||||
- Restores dependencies
|
- Restores dependencies
|
||||||
|
- Runs frontend lint and format checks
|
||||||
- Builds with warnings treated as errors
|
- Builds with warnings treated as errors
|
||||||
- Runs `GameList.Tests`
|
- Runs `GameList.Tests`
|
||||||
|
|||||||
17
REVIEW.md
17
REVIEW.md
@@ -6,20 +6,12 @@ This document tracks only active work. Completed work is intentionally omitted a
|
|||||||
|
|
||||||
Active maintainability risks (priority order):
|
Active maintainability risks (priority order):
|
||||||
|
|
||||||
1. Static analysis and frontend lint guardrails are still missing (Medium)
|
1. Externalized frontend content assets are still pending (Low)
|
||||||
- CI currently gates restore/build/test only (`.github/workflows/ci.yml:23`-`.github/workflows/ci.yml:29`).
|
- Translation and FAQ payloads are still embedded in executable JS (`wwwroot/js/i18n.js:1`-`wwwroot/js/i18n.js:799`).
|
||||||
- Impact: style drift and low-signal warnings can enter the codebase undetected.
|
- Impact: content changes require touching behavior modules and increase review noise.
|
||||||
|
|
||||||
## B) Active task list
|
## B) Active task list
|
||||||
|
|
||||||
[P2] Add static analysis and JS lint/format guardrails
|
|
||||||
- Problem: Severity `Medium`, Category `Tooling`. CI does not enforce analyzers or JS lint/format checks.
|
|
||||||
- Evidence: `.github/workflows/ci.yml:23`-`.github/workflows/ci.yml:29`.
|
|
||||||
- Recommendation: add .NET analyzer configuration and ESLint/Prettier checks, then enforce in CI.
|
|
||||||
- Acceptance criteria (testable): CI fails on analyzer/lint violations; local scripts are documented in root docs.
|
|
||||||
- Effort / Risk: `M / Low`.
|
|
||||||
- Dependencies (if any): none.
|
|
||||||
|
|
||||||
[P2] Externalize i18n and FAQ content from executable JS
|
[P2] Externalize i18n and FAQ content from executable JS
|
||||||
- Problem: Severity `Low`, Category `Complexity/Documentation`. Translation and FAQ payloads are embedded in code.
|
- Problem: Severity `Low`, Category `Complexity/Documentation`. Translation and FAQ payloads are embedded in code.
|
||||||
- Evidence: `wwwroot/js/i18n.js:1`-`wwwroot/js/i18n.js:799`.
|
- Evidence: `wwwroot/js/i18n.js:1`-`wwwroot/js/i18n.js:799`.
|
||||||
@@ -30,8 +22,7 @@ Active maintainability risks (priority order):
|
|||||||
|
|
||||||
## C) Suggested execution order
|
## C) Suggested execution order
|
||||||
|
|
||||||
1. Add analyzers + JS lint gates in CI.
|
1. Externalize i18n/FAQ assets.
|
||||||
2. Externalize i18n/FAQ assets.
|
|
||||||
|
|
||||||
## D) Guardrails
|
## D) Guardrails
|
||||||
|
|
||||||
|
|||||||
21
eslint.config.js
Normal file
21
eslint.config.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
files: ["wwwroot/**/*.js"],
|
||||||
|
...js.configs.recommended,
|
||||||
|
languageOptions: {
|
||||||
|
...js.configs.recommended.languageOptions,
|
||||||
|
ecmaVersion: 2024,
|
||||||
|
sourceType: "module",
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...js.configs.recommended.rules,
|
||||||
|
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
1101
package-lock.json
generated
Normal file
1101
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
package.json
Normal file
16
package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "picknplay-frontend",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint \"wwwroot/**/*.js\"",
|
||||||
|
"format": "prettier --write \"eslint.config.js\" \"wwwroot/js/{admin-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\"",
|
||||||
|
"format:check": "prettier --check \"eslint.config.js\" \"wwwroot/js/{admin-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\""
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "9.21.0",
|
||||||
|
"eslint": "9.21.0",
|
||||||
|
"globals": "15.15.0",
|
||||||
|
"prettier": "3.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { api, adminApi } from "./js/api.js";
|
import { api, adminApi } from "./js/api.js";
|
||||||
import { t, setLanguage, getLanguage, initI18n, onLanguageChange, faqMarkdown } from "./js/i18n.js";
|
import { t, setLanguage, getLanguage, initI18n, onLanguageChange, faqMarkdown } from "./js/i18n.js";
|
||||||
import { state, clearUserState, getSavedUsername, setSavedUsername } from "./js/state.js";
|
import { state, clearUserState, setSavedUsername } from "./js/state.js";
|
||||||
import { $, toast } from "./js/dom.js";
|
import { $, toast } from "./js/dom.js";
|
||||||
import {
|
import {
|
||||||
setAuthUI,
|
setAuthUI,
|
||||||
@@ -22,10 +22,8 @@ import {
|
|||||||
configureUiRuntime,
|
configureUiRuntime,
|
||||||
} from "./js/ui.js";
|
} from "./js/ui.js";
|
||||||
import {
|
import {
|
||||||
loadState,
|
|
||||||
loadSuggestData,
|
loadSuggestData,
|
||||||
loadVoteData,
|
loadVoteData,
|
||||||
loadResults,
|
|
||||||
refreshPhaseData,
|
refreshPhaseData,
|
||||||
} from "./js/data.js";
|
} from "./js/data.js";
|
||||||
initI18n();
|
initI18n();
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ function displayPlayerStatus(player) {
|
|||||||
if (!player) return "";
|
if (!player) return "";
|
||||||
const phase = player.phase;
|
const phase = player.phase;
|
||||||
if (phase === "Suggest") return t("admin.statusSuggesting");
|
if (phase === "Suggest") return t("admin.statusSuggesting");
|
||||||
if (phase === "Vote") return player.finalized ? t("admin.statusFinished") : t("admin.statusVoting");
|
if (phase === "Vote")
|
||||||
|
return player.finalized
|
||||||
|
? t("admin.statusFinished")
|
||||||
|
: t("admin.statusVoting");
|
||||||
if (phase === "Results") return t("admin.statusFinished");
|
if (phase === "Results") return t("admin.statusFinished");
|
||||||
return phase;
|
return phase;
|
||||||
}
|
}
|
||||||
@@ -59,7 +62,9 @@ export function renderAdminLinker() {
|
|||||||
|
|
||||||
const previousSource = source.value;
|
const previousSource = source.value;
|
||||||
const previousTarget = target.value;
|
const previousTarget = target.value;
|
||||||
const options = (state.allSuggestions ?? []).slice().sort((a, b) => a.name.localeCompare(b.name));
|
const options = (state.allSuggestions ?? [])
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
const fillSelect = (select, placeholderKey) => {
|
const fillSelect = (select, placeholderKey) => {
|
||||||
select.innerHTML = "";
|
select.innerHTML = "";
|
||||||
@@ -81,8 +86,10 @@ export function renderAdminLinker() {
|
|||||||
fillSelect(source, "admin.linkSourcePlaceholder");
|
fillSelect(source, "admin.linkSourcePlaceholder");
|
||||||
fillSelect(target, "admin.linkTargetPlaceholder");
|
fillSelect(target, "admin.linkTargetPlaceholder");
|
||||||
|
|
||||||
if (previousSource && options.some((s) => String(s.id) === previousSource)) source.value = previousSource;
|
if (previousSource && options.some((s) => String(s.id) === previousSource))
|
||||||
if (previousTarget && options.some((s) => String(s.id) === previousTarget)) target.value = previousTarget;
|
source.value = previousSource;
|
||||||
|
if (previousTarget && options.some((s) => String(s.id) === previousTarget))
|
||||||
|
target.value = previousTarget;
|
||||||
|
|
||||||
const preventSameSelection = () => {
|
const preventSameSelection = () => {
|
||||||
const sourceVal = source.value;
|
const sourceVal = source.value;
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ export function openLightbox(url, title) {
|
|||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openConfirmModal({ title, body, confirmLabel, cancelLabel = t("modal.cancel"), onConfirm }) {
|
export function openConfirmModal({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
confirmLabel,
|
||||||
|
cancelLabel = t("modal.cancel"),
|
||||||
|
onConfirm,
|
||||||
|
}) {
|
||||||
const overlay = document.createElement("div");
|
const overlay = document.createElement("div");
|
||||||
overlay.className = "edit-modal";
|
overlay.className = "edit-modal";
|
||||||
const panel = document.createElement("div");
|
const panel = document.createElement("div");
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { t } from "./i18n.js";
|
import { t } from "./i18n.js";
|
||||||
import { state } from "./state.js";
|
import { state } from "./state.js";
|
||||||
import { $ } from "./dom.js";
|
import { $ } from "./dom.js";
|
||||||
import { linkRootId, renderLinkBadge, escapeHtml, safeUrl } from "./ui-utils.js";
|
import {
|
||||||
|
linkRootId,
|
||||||
|
renderLinkBadge,
|
||||||
|
escapeHtml,
|
||||||
|
safeUrl,
|
||||||
|
} from "./ui-utils.js";
|
||||||
import { scoreToEmoji } from "./votes-ui.js";
|
import { scoreToEmoji } from "./votes-ui.js";
|
||||||
import { openLightbox } from "./modals-ui.js";
|
import { openLightbox } from "./modals-ui.js";
|
||||||
|
|
||||||
@@ -35,9 +40,23 @@ export function renderResults() {
|
|||||||
rank = nextRank++;
|
rank = nextRank++;
|
||||||
rankByRoot.set(root, rank);
|
rankByRoot.set(root, rank);
|
||||||
}
|
}
|
||||||
const medal = rank === 1 ? "🥇" : rank === 2 ? "🥈" : rank === 3 ? "🥉" : `${rank}`;
|
const medal =
|
||||||
|
rank === 1
|
||||||
|
? "🥇"
|
||||||
|
: rank === 2
|
||||||
|
? "🥈"
|
||||||
|
: rank === 3
|
||||||
|
? "🥉"
|
||||||
|
: `${rank}`;
|
||||||
const row = document.createElement("tr");
|
const row = document.createElement("tr");
|
||||||
const podiumClass = rank === 1 ? "podium podium-1" : rank === 2 ? "podium podium-2" : rank === 3 ? "podium podium-3" : "";
|
const podiumClass =
|
||||||
|
rank === 1
|
||||||
|
? "podium podium-1"
|
||||||
|
: rank === 2
|
||||||
|
? "podium podium-2"
|
||||||
|
: rank === 3
|
||||||
|
? "podium podium-3"
|
||||||
|
: "";
|
||||||
row.className = podiumClass;
|
row.className = podiumClass;
|
||||||
const safeName = escapeHtml(r.name);
|
const safeName = escapeHtml(r.name);
|
||||||
const safeAuthor = escapeHtml(r.author ?? "—");
|
const safeAuthor = escapeHtml(r.author ?? "—");
|
||||||
@@ -79,9 +98,14 @@ export function renderResults() {
|
|||||||
function buildResultMeta(r) {
|
function buildResultMeta(r) {
|
||||||
const hasPlayers = r.minPlayers || r.maxPlayers;
|
const hasPlayers = r.minPlayers || r.maxPlayers;
|
||||||
const players = hasPlayers
|
const players = hasPlayers
|
||||||
? t("card.players", { min: r.minPlayers ?? "?", max: r.maxPlayers ?? "?" })
|
? t("card.players", {
|
||||||
|
min: r.minPlayers ?? "?",
|
||||||
|
max: r.maxPlayers ?? "?",
|
||||||
|
})
|
||||||
: null;
|
: null;
|
||||||
const bits = [r.genre ? escapeHtml(r.genre) : null, players].filter(Boolean);
|
const bits = [r.genre ? escapeHtml(r.genre) : null, players].filter(
|
||||||
|
Boolean,
|
||||||
|
);
|
||||||
if (bits.length === 0) return "";
|
if (bits.length === 0) return "";
|
||||||
return `<div class="muted small">${bits.join(" • ")}</div>`;
|
return `<div class="muted small">${bits.join(" • ")}</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
escapeHtml,
|
escapeHtml,
|
||||||
isLinked,
|
isLinked,
|
||||||
linkedPeerTitles,
|
linkedPeerTitles,
|
||||||
renderLinkBadge,
|
|
||||||
safeUrl,
|
safeUrl,
|
||||||
sortByName,
|
sortByName,
|
||||||
} from "./ui-utils.js";
|
} from "./ui-utils.js";
|
||||||
@@ -23,7 +22,9 @@ function updateSuggestButtonState() {
|
|||||||
const count = state.mySuggestions?.length ?? 0;
|
const count = state.mySuggestions?.length ?? 0;
|
||||||
const blocked = count >= limit;
|
const blocked = count >= limit;
|
||||||
btn.disabled = blocked || state.phase !== "Suggest";
|
btn.disabled = blocked || state.phase !== "Suggest";
|
||||||
btn.textContent = blocked ? t("suggest.maxReached") : t("suggest.addButton");
|
btn.textContent = blocked
|
||||||
|
? t("suggest.maxReached")
|
||||||
|
: t("suggest.addButton");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderMySuggestions() {
|
export function renderMySuggestions() {
|
||||||
@@ -35,7 +36,12 @@ export function renderMySuggestions() {
|
|||||||
const allowDelete = state.phase === "Suggest" || state.me?.isAdmin;
|
const allowDelete = state.phase === "Suggest" || state.me?.isAdmin;
|
||||||
sortByName(state.mySuggestions).forEach((s) =>
|
sortByName(state.mySuggestions).forEach((s) =>
|
||||||
wrap.appendChild(
|
wrap.appendChild(
|
||||||
buildCard(s, { showAuthor: false, allowDelete, allowEdit, lockTitle }),
|
buildCard(s, {
|
||||||
|
showAuthor: false,
|
||||||
|
allowDelete,
|
||||||
|
allowEdit,
|
||||||
|
lockTitle,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
updateSuggestButtonState();
|
updateSuggestButtonState();
|
||||||
@@ -69,7 +75,12 @@ export function renderPhaseTitles() {
|
|||||||
|
|
||||||
export function buildCard(
|
export function buildCard(
|
||||||
s,
|
s,
|
||||||
{ showAuthor = false, allowDelete = false, allowEdit = false, lockTitle = false },
|
{
|
||||||
|
showAuthor = false,
|
||||||
|
allowDelete = false,
|
||||||
|
allowEdit = false,
|
||||||
|
lockTitle = false,
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const card = document.createElement("article");
|
const card = document.createElement("article");
|
||||||
card.className = "game-card";
|
card.className = "game-card";
|
||||||
@@ -92,9 +103,10 @@ export function buildCard(
|
|||||||
const linkChip = linked
|
const linkChip = linked
|
||||||
? `<button class="chip icon link-chip${state.me?.isAdmin ? " link-chip-action" : ""}" data-unlink="${s.id}" type="button" title="${linkTooltipSafe}">🔗</button>`
|
? `<button class="chip icon link-chip${state.me?.isAdmin ? " link-chip-action" : ""}" data-unlink="${s.id}" type="button" title="${linkTooltipSafe}">🔗</button>`
|
||||||
: "";
|
: "";
|
||||||
const visual = hasImage && safeShot
|
const visual =
|
||||||
? `<button class="card-visual" data-img="${safeShot}" aria-label="${t("card.openScreenshot")}" style="background-image:url('${cssEscapeUrl(safeShot)}')"></button>`
|
hasImage && safeShot
|
||||||
: `<div class="card-visual"></div>`;
|
? `<button class="card-visual" data-img="${safeShot}" aria-label="${t("card.openScreenshot")}" style="background-image:url('${cssEscapeUrl(safeShot)}')"></button>`
|
||||||
|
: `<div class="card-visual"></div>`;
|
||||||
const hasPlayers = s.minPlayers || s.maxPlayers;
|
const hasPlayers = s.minPlayers || s.maxPlayers;
|
||||||
const players = hasPlayers
|
const players = hasPlayers
|
||||||
? `${t("card.players", {
|
? `${t("card.players", {
|
||||||
@@ -252,9 +264,13 @@ function buildSuggestionForm(initial = {}, lockTitle = false) {
|
|||||||
return form;
|
return form;
|
||||||
|
|
||||||
function initCharCounters(formEl) {
|
function initCharCounters(formEl) {
|
||||||
const inputs = formEl.querySelectorAll("input[maxlength], textarea[maxlength]");
|
const inputs = formEl.querySelectorAll(
|
||||||
|
"input[maxlength], textarea[maxlength]",
|
||||||
|
);
|
||||||
inputs.forEach((input) => {
|
inputs.forEach((input) => {
|
||||||
const counter = formEl.querySelector(`.char-counter[data-for="${input.name}"]`);
|
const counter = formEl.querySelector(
|
||||||
|
`.char-counter[data-for="${input.name}"]`,
|
||||||
|
);
|
||||||
if (!counter) return;
|
if (!counter) return;
|
||||||
const update = () => {
|
const update = () => {
|
||||||
const max = input.maxLength;
|
const max = input.maxLength;
|
||||||
@@ -268,7 +284,13 @@ function buildSuggestionForm(initial = {}, lockTitle = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSuggestionModal({ title, submitLabel, initial = {}, onSubmit, lockTitle = false }) {
|
function openSuggestionModal({
|
||||||
|
title,
|
||||||
|
submitLabel,
|
||||||
|
initial = {},
|
||||||
|
onSubmit,
|
||||||
|
lockTitle = false,
|
||||||
|
}) {
|
||||||
const overlay = document.createElement("div");
|
const overlay = document.createElement("div");
|
||||||
overlay.className = "edit-modal";
|
overlay.className = "edit-modal";
|
||||||
const panel = document.createElement("div");
|
const panel = document.createElement("div");
|
||||||
@@ -323,7 +345,8 @@ function openSuggestionModal({ title, submitLabel, initial = {}, onSubmit, lockT
|
|||||||
clearError();
|
clearError();
|
||||||
const min = data.minPlayers;
|
const min = data.minPlayers;
|
||||||
const max = data.maxPlayers;
|
const max = data.maxPlayers;
|
||||||
const inRange = (v) => v == null || (Number.isInteger(v) && v >= 1 && v <= 32);
|
const inRange = (v) =>
|
||||||
|
v == null || (Number.isInteger(v) && v >= 1 && v <= 32);
|
||||||
const valid =
|
const valid =
|
||||||
inRange(min) &&
|
inRange(min) &&
|
||||||
inRange(max) &&
|
inRange(max) &&
|
||||||
@@ -450,7 +473,9 @@ function openDeleteConfirmModal(s) {
|
|||||||
|
|
||||||
function openUnlinkConfirm(s) {
|
function openUnlinkConfirm(s) {
|
||||||
const peers = linkedPeerTitles(s);
|
const peers = linkedPeerTitles(s);
|
||||||
const names = peers.length ? peers.join(", ") : t("admin.unlinkUnknownPeers");
|
const names = peers.length
|
||||||
|
? peers.join(", ")
|
||||||
|
: t("admin.unlinkUnknownPeers");
|
||||||
openConfirmModal({
|
openConfirmModal({
|
||||||
title: t("admin.unlinkTitle"),
|
title: t("admin.unlinkTitle"),
|
||||||
body: t("admin.unlinkBody", { name: s.name, peers: names }),
|
body: t("admin.unlinkBody", { name: s.name, peers: names }),
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { state } from "./state.js";
|
|||||||
export const sortByName = (items) =>
|
export const sortByName = (items) =>
|
||||||
(items ?? [])
|
(items ?? [])
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
|
.sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
||||||
|
);
|
||||||
|
|
||||||
export const truncate = (text, max) => {
|
export const truncate = (text, max) => {
|
||||||
if (!text) return "";
|
if (!text) return "";
|
||||||
|
|||||||
@@ -10,9 +10,20 @@ import {
|
|||||||
openNewSuggestionModal,
|
openNewSuggestionModal,
|
||||||
normalizeSuggestionForm,
|
normalizeSuggestionForm,
|
||||||
} from "./suggestions-ui.js";
|
} from "./suggestions-ui.js";
|
||||||
import { renderVotes, scoreToEmoji, syncVoteScores, neutralEmoji, updatePhaseNav } from "./votes-ui.js";
|
import {
|
||||||
|
renderVotes,
|
||||||
|
scoreToEmoji,
|
||||||
|
syncVoteScores,
|
||||||
|
neutralEmoji,
|
||||||
|
updatePhaseNav,
|
||||||
|
} from "./votes-ui.js";
|
||||||
import { renderResults } from "./results-ui.js";
|
import { renderResults } from "./results-ui.js";
|
||||||
import { openConfirmModal, openLightbox, openResultsRelockModal, openSuggestionsChangedModal } from "./modals-ui.js";
|
import {
|
||||||
|
openConfirmModal,
|
||||||
|
openLightbox,
|
||||||
|
openResultsRelockModal,
|
||||||
|
openSuggestionsChangedModal,
|
||||||
|
} from "./modals-ui.js";
|
||||||
|
|
||||||
export function setAuthUI(isAuthed) {
|
export function setAuthUI(isAuthed) {
|
||||||
const main = document.querySelector("main");
|
const main = document.querySelector("main");
|
||||||
@@ -74,9 +85,9 @@ export function handleAuthError(err, clearUserState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderPhasePill() {
|
export function renderPhasePill() {
|
||||||
document.querySelectorAll(".phase-view").forEach((el) =>
|
document
|
||||||
el.classList.add("hidden"),
|
.querySelectorAll(".phase-view")
|
||||||
);
|
.forEach((el) => el.classList.add("hidden"));
|
||||||
const viewMap = {
|
const viewMap = {
|
||||||
Suggest: "suggest-view",
|
Suggest: "suggest-view",
|
||||||
Vote: "vote-view",
|
Vote: "vote-view",
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ export function renderVotes() {
|
|||||||
const warn = $("warn-" + suggestionId);
|
const warn = $("warn-" + suggestionId);
|
||||||
const fallbackValue = prevScore ?? 5;
|
const fallbackValue = prevScore ?? 5;
|
||||||
const fallbackDisplay = prevScore ?? "—";
|
const fallbackDisplay = prevScore ?? "—";
|
||||||
const fallbackEmoji = prevScore != null ? scoreToEmoji(prevScore) : "⚠️";
|
const fallbackEmoji =
|
||||||
|
prevScore != null ? scoreToEmoji(prevScore) : "⚠️";
|
||||||
e.target.value = fallbackValue;
|
e.target.value = fallbackValue;
|
||||||
if (label) label.textContent = fallbackDisplay;
|
if (label) label.textContent = fallbackDisplay;
|
||||||
if (emoji) emoji.textContent = fallbackEmoji;
|
if (emoji) emoji.textContent = fallbackEmoji;
|
||||||
@@ -89,7 +90,9 @@ export function renderVotes() {
|
|||||||
linkedIds.forEach((id) => {
|
linkedIds.forEach((id) => {
|
||||||
const peerWarn = $("warn-" + id);
|
const peerWarn = $("warn-" + id);
|
||||||
if (peerWarn) peerWarn.classList.add("hidden");
|
if (peerWarn) peerWarn.classList.add("hidden");
|
||||||
const peerSlider = document.querySelector(`input[type=range][data-id="${id}"]`);
|
const peerSlider = document.querySelector(
|
||||||
|
`input[type=range][data-id="${id}"]`,
|
||||||
|
);
|
||||||
if (peerSlider) delete peerSlider.dataset.pending;
|
if (peerSlider) delete peerSlider.dataset.pending;
|
||||||
});
|
});
|
||||||
await getUiRuntime().loadVoteData();
|
await getUiRuntime().loadVoteData();
|
||||||
@@ -174,7 +177,9 @@ function syncLinkedSliders(sourceEl, value) {
|
|||||||
if (!linkedAttr) return;
|
if (!linkedAttr) return;
|
||||||
const ids = linkedAttr.split(",").filter(Boolean);
|
const ids = linkedAttr.split(",").filter(Boolean);
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
const slider = document.querySelector(`input[type=range][data-id="${id}"]`);
|
const slider = document.querySelector(
|
||||||
|
`input[type=range][data-id="${id}"]`,
|
||||||
|
);
|
||||||
if (!slider || slider === sourceEl) return;
|
if (!slider || slider === sourceEl) return;
|
||||||
slider.value = value;
|
slider.value = value;
|
||||||
const scoreLabel = $("score-" + id);
|
const scoreLabel = $("score-" + id);
|
||||||
@@ -206,7 +211,9 @@ export function updatePhaseNav() {
|
|||||||
|
|
||||||
const finalizeBtn = $("finalize-votes");
|
const finalizeBtn = $("finalize-votes");
|
||||||
if (finalizeBtn) {
|
if (finalizeBtn) {
|
||||||
finalizeBtn.textContent = state.votesFinal ? t("vote.unfinalize") : t("vote.finalize");
|
finalizeBtn.textContent = state.votesFinal
|
||||||
|
? t("vote.unfinalize")
|
||||||
|
: t("vote.finalize");
|
||||||
}
|
}
|
||||||
|
|
||||||
const voteMissingBadge = $("vote-missing");
|
const voteMissingBadge = $("vote-missing");
|
||||||
@@ -226,7 +233,9 @@ export function updatePhaseNav() {
|
|||||||
|
|
||||||
const voteStatusText = $("vote-status-text");
|
const voteStatusText = $("vote-status-text");
|
||||||
if (voteStatusText) {
|
if (voteStatusText) {
|
||||||
voteStatusText.textContent = state.votesFinal ? t("nav.voteFinalized") : t("nav.voteHint");
|
voteStatusText.textContent = state.votesFinal
|
||||||
|
? t("nav.voteFinalized")
|
||||||
|
: t("nav.voteHint");
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAdminVoteStatus();
|
renderAdminVoteStatus();
|
||||||
@@ -243,7 +252,9 @@ export function updatePhaseNav() {
|
|||||||
if (voteNext) {
|
if (voteNext) {
|
||||||
const locked = !state.resultsOpen && !isAdmin;
|
const locked = !state.resultsOpen && !isAdmin;
|
||||||
voteNext.disabled = locked;
|
voteNext.disabled = locked;
|
||||||
voteNext.textContent = locked ? t("nav.waitingForResults") : t("nav.next");
|
voteNext.textContent = locked
|
||||||
|
? t("nav.waitingForResults")
|
||||||
|
: t("nav.next");
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminResultsToggle = $("results-open");
|
const adminResultsToggle = $("results-open");
|
||||||
|
|||||||
Reference in New Issue
Block a user