diff --git a/.claude/skills/e2e/SKILL.md b/.claude/skills/e2e/SKILL.md index 4afe22829..2967690ee 100644 --- a/.claude/skills/e2e/SKILL.md +++ b/.claude/skills/e2e/SKILL.md @@ -1,6 +1,6 @@ --- name: e2e -description: Run, debug, and manage Playwright e2e tests. Use when running e2e tests, debugging test failures, regenerating seed databases, or investigating test infrastructure issues. +description: Run, debug, and manage Playwright e2e tests. Use when running e2e tests, debugging test failures, writing new specs, or investigating test infrastructure issues. --- # E2E Test Runner @@ -8,27 +8,26 @@ description: Run, debug, and manage Playwright e2e tests. Use when running e2e t ## Architecture overview - Tests live in `e2e/*.spec.ts`, config in `playwright.config.ts` -- Global setup (`e2e/global-setup.ts`) builds the app, creates per-worker databases, and starts one server per worker -- Port calculation: `E2E_BASE_PORT = PORT (from .env) + 500`. Default PORT is typically 4001, so base port = 4501. Workers use ports base+0 through base+3 -- Worker databases: `db-test-e2e-0.sqlite3` through `db-test-e2e-3.sqlite3` in the project root -- Seed databases (pre-seeded snapshots): `e2e/seeds/db-seed-*.sqlite3` +- Page objects live in `e2e/pages//` — every spec uses them; conventions in `docs/dev/e2e-page-objects.md`, gotchas in `docs/dev/e2e-page-objects-migration.md` +- Global setup (`e2e/global-setup.ts`) builds the app (skipped when no build input changed since the last e2e build — tracked via `.e2e-build-marker`), creates/migrates per-worker databases (via `scripts/ensure-test-db.ts`: pending migrations are applied, drifted databases are rebuilt), and starts one server per worker +- Port calculation: `E2E_BASE_PORT = PORT (from .env) + 500`. Worker N uses port base+N, except ports on the WHATWG fetch bad port list (e.g. 6679) are skipped — see `e2eWorkerPort` in `e2e/helpers/playwright.ts` +- Worker count: `E2E_WORKERS` env, defaulting to `min(8, max(4, cores - 2))` +- Worker databases: `db-test-e2e-.sqlite3` in the project root; every test starts from a wiped database holding only the admin (Sendou) and N-ZAP users, and builds its own data with the `factories` fixture - MinIO (S3-compatible storage) is started via Docker Compose if not already running ## Pre-flight checks (run before every test execution) Before running tests, check for these common issues: -1. **Stale worker databases** — Files matching `db-test-e2e-*.sqlite3` in the project root can cause "table already exists" migration errors if the schema has changed since they were created. Run `pnpm run test:e2e:generate-seeds` to regenerate these from the seed databases. - -2. **Port conflicts** — Check if anything is already listening on the e2e ports (base port through base+3): +1. **Port conflicts** — Check if anything is already listening on the e2e ports (base port + worker index): ``` - lsof -i :4501-4504 2>/dev/null + lsof -i :4501-4508 2>/dev/null ``` If ports are occupied by leftover e2e servers, kill them. If occupied by something else, warn the user. -3. **Seed databases exist** — Verify `e2e/seeds/` contains the expected seed files. If missing, run `pnpm run test:e2e:generate-seeds`. +2. **Docker running** — MinIO requires Docker. Check with `docker info` if there are storage-related failures. -4. **Docker running** — MinIO requires Docker. Check with `docker info` if there are storage-related failures. +Stale worker databases (`db-test-e2e-*.sqlite3`) are handled automatically: global setup applies pending migrations and rebuilds databases whose migration history has drifted. ## Running tests @@ -41,16 +40,18 @@ pnpm run test:e2e ```bash pnpm exec playwright test e2e/.spec.ts ``` +Batch multiple files into one invocation — every invocation pays global setup. ### Flaky detection (repeats each test 10 times, stops on first failure) ```bash pnpm run test:e2e:flaky-detect ``` -### Regenerate seed databases (after schema/migration changes) +### Force a rebuild of the app ```bash -pnpm run test:e2e:generate-seeds +E2E_FORCE_BUILD=true pnpm run test:e2e ``` +Global setup reuses the previous build when nothing under `app/`, `public/`, the lockfile, or the vite/react-router configs changed. Use this override if you suspect a stale build (e.g. after changing env-dependent build behavior). ## Debugging failures @@ -62,10 +63,10 @@ Follow this funnel when tests fail: ### Step 2: Check infrastructure issues Common infrastructure errors and fixes: -- **"table already exists"** → Stale worker DBs. Run `rm -f db-test-e2e-*.sqlite3` +- **"table already exists"** → Should not happen anymore (global setup rebuilds drifted worker DBs); if it does, `rm -f db-test-e2e-*.sqlite3` and investigate `scripts/ensure-test-db.ts` - **"Server on port X did not start within timeout"** → Port conflict or app build error. Check ports with `lsof -i :` and check for build errors - **"MinIO failed to start"** → Docker not running or compose issue. Check `docker info` -- **Seed-related errors** → Run `pnpm run test:e2e:generate-seeds` +- **"Test ended with database writes the server never saw"** → A factory call was not followed by a helper that talks to the server; add a `navigate`/`impersonate` after the writes ### Step 3: Reduce to single debug worker If the error is unclear, re-run with debug output and a single worker to see server logs: @@ -75,41 +76,50 @@ E2E_DEBUG=true E2E_WORKERS=1 pnpm exec playwright test e2e/.spec.t This shows stdout/stderr from the test server, which is hidden by default. ### Step 4: Examine trace artifacts -Playwright is configured with `trace: "retain-on-failure"`. After a failure, view the trace: +Playwright is configured with `trace: "retain-on-failure"`. After a failure, check `test-results//error-context.md` for the page's accessibility snapshot at failure time, or view the trace: ```bash pnpm exec playwright show-trace test-results//trace.zip ``` +### Re-render races +Skalop (websocket) is fully disconnected in e2e — the build has an empty `VITE_SKALOP_WS_URL` and worker servers get empty `SKALOP_SYSTEM_MESSAGE_URL`/`SKALOP_TOKEN` (see `e2e/global-setup.ts`), so cross-worker websocket crosstalk cannot cause flakes. Google Fonts are also blocked at the context level so font swaps never reflow the page mid-test. Re-renders from the test's own action revalidations can still swallow a React Aria press (press start registers, press end never fires — no POST); `waitForPOSTResponse` retries for this, so route flows through it rather than adding sleeps. When e2e tests for chat/websocket features are added, skalop needs a per-worker instance or stub with a runtime-derived WS URL. + ## Test pattern reference -Every test follows this pattern — use these imports from `./helpers/playwright`, NOT raw Playwright APIs: +Every test builds its own data with factories and drives the UI through page objects: ```typescript -import { expect, impersonate, navigate, seed, test } from "./helpers/playwright"; +import { NZAP_TEST_ID } from "~/db/seed/constants"; +import { expect, impersonate, test } from "./helpers/playwright"; +import { BuildsPage } from "./pages/builds/builds-page"; test.describe("Feature", () => { - test("does something", async ({ page }) => { - await seed(page); // Reset DB to a known seed state - await impersonate(page, USER_ID); // Log in as a specific user (default: admin) - await navigate({ page, url: "..." });// Navigate (waits for hydration) - // ... interact with the page ... - await submit(page); // Submit a form (waits for POST response) - }); + test("does something", async ({ page, factories }) => { + await factories.BuildFactory.create({ ownerId: NZAP_TEST_ID }); + + await impersonate(page, NZAP_TEST_ID); + const builds = new BuildsPage(page); + await builds.goto(); + // ... interact via page object methods, assert in the spec ... + }); }); ``` Key rules: -- Use `navigate()` instead of `page.goto()` — it waits for hydration +- The database starts each test holding only the admin and N-ZAP; the `factories` worker fixture (see `e2e/helpers/factories.ts` for the registry) creates everything else +- Locators live in page objects under `e2e/pages/` — specs contain no raw `getByTestId`/`getByRole` calls; see `docs/dev/e2e-page-objects.md` +- Use `navigate()` instead of `page.goto()` — it waits for hydration (page objects' `goto()` methods wrap it) - Use `submit()` instead of clicking submit buttons directly — it waits for the POST response -- Use `seed(page, variation?)` to reset the database. Available variations: DEFAULT, NO_TOURNAMENT_TEAMS, REG_OPEN, SMALL_SOS, NZAP_IN_TEAM, NO_SCRIMS, NO_SQ_GROUPS -- Use `impersonate(page, userId?)` to authenticate. Default is admin (ADMIN_ID) +- Use `impersonate(page, userId?)` to authenticate. Default is admin (ADMIN_ID); prefer N-ZAP (`NZAP_TEST_ID`) when the flow doesn't need admin rights - Avoid `page.waitForTimeout` — use assertions or `waitFor` patterns instead -- Import `test` from `./helpers/playwright` (not from `@playwright/test`) — it includes worker port fixtures +- Import `test` from `./helpers/playwright` (not from `@playwright/test`) — it includes worker port fixtures and the database reset +- Factory writes must be followed by a helper that talks to the server (`navigate`, `impersonate`, `submit`) or the test fails with "writes the server never saw" ## Environment variables | Variable | Purpose | Default | |----------|---------|---------| -| `E2E_WORKERS` | Number of parallel workers | 4 | +| `E2E_WORKERS` | Number of parallel workers | min(8, max(4, cores − 2)) | | `E2E_DEBUG` | Show server stdout/stderr when "true" | unset | +| `E2E_FORCE_BUILD` | Rebuild the app even when inputs look unchanged | unset | | `PORT` | Base port for dev server (e2e adds 500) | 5173 | diff --git a/.claude/skills/search-params/SKILL.md b/.claude/skills/search-params/SKILL.md new file mode 100644 index 000000000..8edcf9aee --- /dev/null +++ b/.claude/skills/search-params/SKILL.md @@ -0,0 +1,10 @@ +--- +name: search-params +description: Work with URL search param state via the unified app/modules/search-params system. Use when reading or writing query strings, URL-backed state, filters-in-URL, pagination, share links, or shouldRevalidate related to search params. +--- + +# Search params + +All URL search param handling goes through `app/modules/search-params/`. + +**Read [docs/dev/search-params.md](../../../docs/dev/search-params.md) now** — it is the single source of truth for the conventions (hard rules, defining params, the codec derivation table, loader/client APIs, href building, revalidation, testing). diff --git a/.claude/skills/sendou-code-review/SKILL.md b/.claude/skills/sendou-code-review/SKILL.md index 834914d09..abb48043d 100644 --- a/.claude/skills/sendou-code-review/SKILL.md +++ b/.claude/skills/sendou-code-review/SKILL.md @@ -1,9 +1,9 @@ --- name: sendou-code-review -description: Multi-agent code review that checks the current diff from 7 angles (spec compliance, modernization, bugs, CLAUDE.md rules, abstraction reuse, security, DB query performance) and produces a unified review. Works on branch diffs vs main or staged changes. +description: Multi-agent code review that checks the current diff from multiple angles (spec compliance, modernization, bugs from two lenses, CLAUDE.md rules, abstraction reuse, security, DB query performance, test coverage, English copy proofreading) plus mechanical pre-checks, adversarially verifies every finding, and produces a unified review. Works on branch diffs vs main or uncommitted changes. --- -Review the current code changes from multiple angles using parallel sub-agents, then synthesize into a single high-quality review. If the diff contains no changes to Repository files or SQL/Kysely code, skip Agent 7 (DB Query Performance). +Review the current code changes from multiple angles using parallel sub-agents, adversarially verify each finding, then synthesize into a single high-quality review. If the diff contains no changes to Repository files or SQL/Kysely code, skip Agent 8 (DB Query Performance). ## Step 1: Determine what to review @@ -11,39 +11,85 @@ Run these commands to figure out what to review: ``` git branch --show-current +git status --porcelain -uall ``` -- If NOT on `main`: get the diff with `git diff main...HEAD` and also `git diff` for any unstaged changes on the branch. Combine them — this is the **full diff**. -- If on `main`: get staged changes with `git diff --cached`. If there are no staged changes, tell the user: "Nothing to review. Either check out a feature branch or stage some changes." and stop. +- If NOT on `main`: get the diff with `git diff main -- ':(exclude)locales/'` — this compares the working tree against main, so it covers committed, staged, AND unstaged changes in one diff. +- If on `main`: get uncommitted changes with `git diff HEAD -- ':(exclude)locales/'` (covers both staged and unstaged). +- **Translation files are excluded**: the `':(exclude)locales/'` pathspec keeps `locales/` JSON churn (mostly `i18n:sync` output) out of the review — it is noise, not reviewable code. Apply the same exclusion to every diff/stat command below. +- **But collect the English copy separately**: also run `git diff main -- 'locales/en/'` (or `git diff HEAD -- 'locales/en/'` on main), plus untracked files under `locales/en/`. This is the **copy diff** — it is NOT part of the full diff and goes only to Agent 10 (Copy Editor). +- **Untracked files**: plain diffs never show untracked files. For every untracked file in the `git status --porcelain -uall` output (these are not gitignored — git status already excludes ignored files), skip anything under `locales/`, then read the file's full content and append it to the review material clearly marked as "NEW UNTRACKED FILE: {path}". These files get reviewed like any other changed code. +- If the diff is empty and there are no untracked files, tell the user: "Nothing to review. Either check out a feature branch or make some changes." and stop. + +The diff plus untracked file contents is the **full diff** referenced below. Also run these in parallel: - `cat .nvmrc` to get the Node.js version - Read the `CLAUDE.md` and `AGENTS.md` files for the project rules -- `git diff main...HEAD --stat` (or `git diff --cached --stat` on main) to get the list of changed files +- `git diff main --stat -- ':(exclude)locales/'` (or `git diff HEAD --stat -- ':(exclude)locales/'` on main) to get the list of changed files -## Step 2: Collect context from the user +## Step 2: Mechanical pre-checks (no agents) -The user may have provided a GitHub issue URL or description of what the code should do as an argument. If they did, this will be used by the Spec Compliance agent. If not, the Spec Compliance agent will be skipped. +These rules are greppable and don't need an LLM. Run them yourself now; each hit becomes a **pre-confirmed finding** passed straight to the summarizer (they skip the verification stage). -## Step 3: Launch up to 7 parallel review agents +1. **Typecheck**: run `pnpm run typecheck`. If it fails on changed files, record each error as a pre-confirmed finding — the review should lead with these rather than agents flagging downstream symptoms. Continue with the review regardless. +2. **Undefined CSS variables**: for every `var(--...)` in added lines of CSS/TSX, verify the variable is defined in `app/styles/vars.css` or locally in one of the changed files. Undefined → finding. +3. **Unregistered routes**: for every new file added under a `routes/` directory (e.g. `app/features/*/routes/*.tsx`), grep `app/routes.ts` for its path. Not referenced → finding. +4. **Missing CSS module pairing**: a new `.tsx` file that defines components with non-trivial styling but has no matching `.module.css` file. Use judgement — files with no styling needs are fine. +5. **Leftover debug code**: grep added lines for `console.log`, `test.only`, `describe.only`, `it.only`. + +## Step 3: Collect the spec + +The user may have provided a GitHub issue URL or description of what the code should do as an argument. If they did, use it for the Spec Compliance agent. + +If they did NOT, derive the intent instead of skipping: +- On a branch: run `git log main..HEAD --format="%s%n%b"` for commit messages. If a commit or the branch name references an issue (`#123`), fetch it with `gh issue view 123`. If a PR exists, fetch `gh pr view --json title,body`. +- Combine whatever you found into the spec. "Does the code do what the commits claim" is still a valuable check. +- Only skip the Spec Compliance agent if there is genuinely nothing: no user input, no commits (e.g. uncommitted changes on main), no PR. + +## Step 4: Launch parallel finder agents Launch these as parallel Agent calls. Each agent receives: -- The full diff +- The full diff (including untracked file contents) - The list of changed files - Access to read the full files for surrounding context +**Sharding for large diffs**: if the full diff exceeds ~2000 lines, do not warn and shrug — shard. Split the changed file list into roughly equal chunks (by line count) and run one instance of each finder agent per chunk, giving each instance its chunk's diff plus the full file list for context. Findings from all instances merge into the same pool. + +**Agent budget — sharding must not explode usage on a massive branch:** +- Cap at **3 shards** no matter the diff size. +- If the diff exceeds ~4000 lines, only shard the high-signal finders (Agents 1, 3, 4, 7 — spec, both bug lenses, security). Run the rest (modernizer, CLAUDE.md, abstraction, test coverage) as a single instance each over the full diff; they degrade gracefully with size. +- Hard ceiling: **at most 20 finder agent calls** total. If the plan would exceed that, drop shards from the lowest-priority finders first (modernization, then CLAUDE.md, then abstraction). +- If the diff exceeds ~8000 lines, stop and ask the user before launching anything: proceed with the capped review (state the approximate agent count), or narrow the review to a subset they pick (e.g. specific directories/features), or review only the riskiest files (Repositories, actions/loaders, permission-related code) — recommend the last option. + **Important for all agents:** - Only flag issues in the NEW code (lines added/changed in the diff). Do not flag pre-existing issues. -- Be specific: cite file paths and describe the exact problem and a concrete suggestion. -- If you find nothing meaningful, say so — do not manufacture issues. +- Be specific: cite file paths and line numbers, describe the exact problem and a concrete suggestion. +- If you find nothing meaningful, return an empty list — do not manufacture issues. -### Agent 1: Spec Compliance (skip if no issue/description provided) +**Structured output — every finder returns JSON**, an array of findings shaped: + +```json +{ + "file": "app/features/foo/FooPage.tsx", + "line": 42, + "category": "bug | security | db-performance | spec | claude-md | abstraction | modernization | test-coverage | copy", + "severity": "critical | warning | info", + "claim": "one-sentence statement of the problem", + "detail": "how it manifests / why it matters, 1-3 sentences", + "suggestion": "concrete fix" +} +``` + +Tell each agent to output ONLY the JSON array as its final message. Agent-specific extra fields are noted per agent below. + +### Agent 1: Spec Compliance (skip only if no spec could be collected in Step 3) ``` You are reviewing code changes for spec compliance. -The user described the expected behavior as: -{user_provided_spec} +The expected behavior (from user description, commit messages, and/or linked issue/PR): +{spec} Here is the diff: {diff} @@ -57,7 +103,7 @@ Read the full changed files for context. Check whether the implementation actual Only flag real gaps between spec and implementation. Do not flag things the spec doesn't mention. -Return a list of issues, each with: file path, description of the gap, and what the spec expected. +Return ONLY a JSON array of findings: [{file, line, category: "spec", severity, claim, detail (what the spec expected vs what the code does), suggestion}] ``` ### Agent 2: Modernizer @@ -82,13 +128,36 @@ Read the full changed files for context. Check for: Only flag cases where the modern approach is clearly better (more readable, shorter, or more performant). Do not flag stylistic preferences that are a wash. -Return a list of suggestions, each with: file path, the current code pattern, and the modern replacement. +Return ONLY a JSON array of findings: [{file, line, category: "modernization", severity: "info", claim (the current pattern), detail, suggestion (the modern replacement)}] ``` -### Agent 3: Bug Finder +### Agent 3: Bug Finder — client lens ``` -You are reviewing code changes for bugs. +You are reviewing code changes for bugs, focused on the CLIENT side: React components, hooks, state, data flow, and UI logic. + +Here is the diff: +{diff} + +Changed files: {file_list} + +Read the full changed files for context. Look for (not a comprehensive list): +- Logic errors in components and hooks (wrong conditions, off-by-one, incorrect comparisons) +- Null/undefined access that could crash at render time +- State management bugs (stale closures, state derived incorrectly from props, missing dependency arrays — but note this project doesn't use useMemo/useCallback) +- Broken data flow between components (props passed but ignored, wrong prop, stale loader data) +- Form handling and optimistic UI mistakes +- Rendering bugs (wrong keys in lists, conditional rendering that hides errors) + +Focus on bugs that would actually manifest in practice. Do not flag theoretical issues that are prevented by the surrounding code or type system. + +Return ONLY a JSON array of findings: [{file, line, category: "bug", severity, claim, detail (how it manifests), suggestion}] +``` + +### Agent 4: Bug Finder — server/data lens + +``` +You are reviewing code changes for bugs, focused on the SERVER side: loaders, actions, Repository code, SQL, validation, and authorization-adjacent logic. Here is the diff: {diff} @@ -98,19 +167,19 @@ Changed files: {file_list} Read the full changed files for context. Look for (not a comprehensive list): - Logic errors (wrong conditions, off-by-one, incorrect comparisons) - Null/undefined access that could crash at runtime -- Race conditions or ordering issues -- Incorrect type assumptions +- Race conditions or ordering issues (concurrent actions, non-atomic read-then-write) +- Incorrect type assumptions at boundaries (request params, DB rows) - Missing error handling at system boundaries (external APIs, user input) -- State management bugs (stale closures, missing dependency arrays — but note this project doesn't use useMemo/useCallback) -- Incorrect SQL queries (wrong joins, missing WHERE clauses, SQL injection) -- Broken data flow between components +- Incorrect SQL queries (wrong joins, missing WHERE clauses, wrong aggregation) +- Transactions missing where multiple writes must be atomic +- Data returned to the client that doesn't match what the component expects Focus on bugs that would actually manifest in practice. Do not flag theoretical issues that are prevented by the surrounding code or type system. -Return a list of bugs, each with: file path, description of the bug, how it would manifest, and a suggested fix. +Return ONLY a JSON array of findings: [{file, line, category: "bug", severity, claim, detail (how it manifests), suggestion}] ``` -### Agent 4: CLAUDE.md Compliance +### Agent 5: CLAUDE.md Compliance ``` You are reviewing code changes for compliance with the project's CLAUDE.md rules. @@ -125,10 +194,10 @@ Changed files: {file_list} Only flag clear violations. If a rule says "prefer" or "avoid", use judgement — a minor deviation in context is not a violation. -Return a list of violations, each with: file path, the rule violated (quote it), and the offending code. +Return ONLY a JSON array of findings: [{file, line, category: "claude-md", severity: "warning", claim (quote the rule violated), detail (the offending code), suggestion}] ``` -### Agent 5: Abstraction Police +### Agent 6: Abstraction Police ``` You are reviewing code changes for proper reuse of existing abstractions and avoiding excessive copy-paste. @@ -145,17 +214,17 @@ Your job is to search the codebase to check: Use the "three strikes" rule: a small amount of duplication (2 instances) is acceptable. Three or more instances of the same pattern means it should be abstracted. -Search broadly — check `app/utils/`, `app/components/`, `app/hooks/`, and files adjacent to the changed files. +Search broadly — check `app/utils/`, `app/components/`, `app/hooks/`, `app/modules/`, and files adjacent to the changed files. Do NOT flag: - Simple one-liners that happen to look similar (e.g., `if (!user) return null`) - Standard patterns that are idiomatic and don't benefit from abstraction - Duplication that exists only in the old code (not introduced by this diff) -Return a list of issues, each with: file path, the duplicated pattern, where it already exists in the codebase (with file paths), and a suggestion for how to share it. +Return ONLY a JSON array of findings: [{file, line, category: "abstraction", severity, claim (the duplicated pattern), detail (where it already exists — cite file paths), suggestion (how to share it)}] ``` -### Agent 6: Security +### Agent 7: Security ``` You are reviewing code changes for security vulnerabilities. @@ -165,28 +234,36 @@ Here is the diff: Changed files: {file_list} -Read the full changed files for context. This is a Remix/React Router web application with SQLite (via Kysely). Check for: +Read the full changed files for context. This is a Remix/React Router web application with SQLite (via Kysely). + +Project auth conventions — check against these real patterns, not generic boilerplate: +- Server-side auth: `requireUser()` from `app/features/auth/core/user.server.ts` returns the authenticated user or throws. +- Role/permission guards: `requireRole(role)` and `requirePermission(...)` from `app/modules/permissions/guards.server.ts`. Helpers like `isAdmin`/`isStaff` live in `app/modules/permissions/utils.ts`. +- Client-side hooks `useHasRole`/`useHasPermission` (`app/modules/permissions/hooks.ts`) are for UI display only — they are NOT security boundaries. Every mutation (action) and sensitive loader must enforce auth server-side. + +Check for: +- Loaders/actions that read or mutate data without the appropriate `requireUser`/`requireRole`/`requirePermission` guard, or that guard with the wrong scope (IDOR — can user A access or mutate user B's data by changing an id?) - SQL injection (even with Kysely, check for raw queries or string interpolation in SQL) - XSS (unescaped user input rendered as HTML, dangerouslySetInnerHTML with user data) -- CSRF vulnerabilities -- Authorization bypasses (missing permission checks, IDOR — can user A access user B's data?) -- Sensitive data exposure (tokens, passwords, or PII in logs, responses, or client-side code) +- Sensitive data exposure (tokens, passwords, or PII in logs, loader responses, or client-side code — remember everything a loader returns is visible to the client) - Path traversal (user-controlled file paths) - Insecure redirects (open redirect via user-controlled URLs) - Missing input validation at system boundaries Focus on real, exploitable vulnerabilities in the new code. Do not flag: - General best-practice advice that isn't a concrete vulnerability -- Issues in frameworks/libraries (Remix, React) that handle security themselves +- Issues in frameworks/libraries (Remix, React) that handle security themselves (e.g. React's default escaping, framework CSRF handling) - Pre-existing issues not introduced by this diff -Return a list of vulnerabilities, each with: file path, vulnerability type (e.g., "SQL Injection"), description, attack scenario, and suggested fix. +Return ONLY a JSON array of findings: [{file, line, category: "security", severity, claim (vulnerability type + summary), detail (attack scenario), suggestion}] ``` -### Agent 7: DB Query Performance (skip if no Repository/Kysely changes in diff) +### Agent 8: DB Query Performance (skip if no Repository/Kysely changes in diff) ``` -You are reviewing code changes for database query performance. This is a Remix/React Router web app using SQLite via Kysely. The dev database is at `db.sqlite3`. +You are reviewing code changes for database query performance. This is a Remix/React Router web app using SQLite via Kysely. + +IMPORTANT: run all analysis against `db-prod.sqlite3` — a copy of the production database (~2GB, safe to experiment with). Do NOT use `db.sqlite3` if `db-prod.sqlite3` exists (the dev db is tiny, so its query plans and table sizes are meaningless for performance analysis). Here is the diff: {diff} @@ -197,82 +274,178 @@ Your job: 1. **Identify new or changed DB queries** in the diff. These live in `*Repository.server.ts` files and use Kysely. Read the full changed Repository files for context. -2. **For each query**, do the following: +2. **Get the real SQL** — do not mentally compile Kysely. Write a small throwaway script at `scripts/tmp-review-compile.ts` (it must live inside the project so vite-node resolves the `~` alias; delete it when you are done) that rebuilds the query with the project's `db` instance and prints the compiled SQL: - a. **Run EXPLAIN QUERY PLAN** against the dev database (`db.sqlite3`) using the Bash tool: + ```ts + import { db } from "~/db/sql"; + + const compiled = db + .selectFrom(/* ...rebuild the query from the Repository code... */) + .compile(); + console.log(compiled.sql); + console.log(compiled.parameters); + ``` + + Run it with: `DB_PATH=db-prod.sqlite3 VITE_PROD_MODE=true pnpm vite-node scripts/tmp-review-compile.ts` (this matches how the project's own `bench:db` script runs). Substitute realistic parameter values from the printed parameters. + +3. **For each query**: + + a. **Run EXPLAIN QUERY PLAN** against the prod copy: ``` - sqlite3 db.sqlite3 "EXPLAIN QUERY PLAN " + sqlite3 db-prod.sqlite3 "EXPLAIN QUERY PLAN " ``` - To get the raw SQL from Kysely, read the query and mentally compile it. Substitute realistic placeholder values for any parameters. - b. **Check for missing indexes**: Look at the EXPLAIN output for "SCAN TABLE" (full table scan) vs "SEARCH TABLE ... USING INDEX" or "USING COVERING INDEX". A SCAN on a large table in a hot path is a red flag. + b. **Measure table sizes — do not guess**: `sqlite3 db-prod.sqlite3 "SELECT COUNT(*) FROM "` for every table the query touches. - c. **Check existing indexes**: Run `sqlite3 db.sqlite3 ".indexes "` and `sqlite3 db.sqlite3 "PRAGMA index_info()"` to see what indexes exist. + c. **Check for missing indexes**: Look at the EXPLAIN output for "SCAN
" (full table scan) vs "SEARCH
... USING INDEX" or "USING COVERING INDEX". A SCAN on a large table in a hot path is a red flag. Check existing indexes with `sqlite3 db-prod.sqlite3 ".indexes "` and `PRAGMA index_info()`. - d. **Assess query context** — reason about: - - **Table size**: Is this a table with thousands/millions of rows (e.g., SplatoonPlayer, Build, GroupMatch) or a small config-like table (e.g., CalendarEventTag, TournamentBadgeOwner)? - - **Call frequency**: Is this query in a hot path (page loader hit on every page view, API called frequently) or a cold path (admin action, background routine, rare user action)? - - **N+1 patterns**: Is the query called inside a loop when it could be batched? - Use the route file or caller to determine how the Repository function is invoked. + d. **Assess call frequency**: Is this query in a hot path (page loader hit on every page view, API called frequently) or a cold path (admin action, background routine, rare user action)? Use the route file or caller to determine how the Repository function is invoked. Also check for N+1 patterns — a query called inside a loop that could be batched. -3. **Severity assessment**: Weight your findings by impact: - - **Critical**: Full table scan on a large table in a hot path, or N+1 query pattern - - **Warning**: Full table scan on a medium table, or missing index on a frequently-filtered column - - **Info**: Scan on a small table or infrequent query — note it but don't flag as a problem +4. **Severity**: + - **critical**: Full table scan on a large table (measured, not guessed) in a hot path, or N+1 query pattern + - **warning**: Full table scan on a medium table, or missing index on a frequently-filtered column + - **info**: Scan on a small table or infrequent query — note it but don't flag as a problem -4. **Do NOT flag**: +5. **Do NOT flag**: - Queries that already use appropriate indexes - - Scans on tiny tables (< ~100 rows) that are accessed infrequently + - Scans on tiny tables (< ~100 measured rows) that are accessed infrequently - Pre-existing queries not changed in this diff -Return a list of findings, each with: file path, the query (or a description of it), EXPLAIN QUERY PLAN output, table size assessment (small/medium/large), call frequency assessment (hot/warm/cold), severity (critical/warning/info), and a concrete suggestion if action is needed (e.g., "add index on X(Y)" or "batch these N queries into one with WHERE IN"). +Return ONLY a JSON array of findings: [{file, line, category: "db-performance", severity, claim, detail (EXPLAIN output + measured row counts + call frequency assessment), suggestion (e.g. "add index on X(Y)" or "batch these N queries into one with WHERE IN")}] ``` -## Step 4: Summarize - -After all agents complete, launch a single summarizer agent that receives ALL agent outputs. +### Agent 9: Test Coverage ``` -You are the final reviewer synthesizing code review feedback from up to 7 specialized agents. +You are reviewing code changes for test coverage gaps. This project uses Vitest for unit/browser tests and Playwright for e2e tests. -Here are their findings: +Here is the diff: +{diff} -{all_agent_outputs} +Changed files: {file_list} + +Compare what logic changed against what tests changed: + +1. **Changed pure-logic Modules** (files imported as `* as Module`, typically with JSDoc'd functions): do they have unit tests (`*.test.ts` alongside or in a nearby location)? Were the tests updated when the logic changed? +2. **New Repository READ functions**: the project convention is that these get benchmark cases in `scripts/benchmark-db` (run via `pnpm run bench:db`). Flag new read functions with no benchmark case. +3. **New routes or significant new user flows**: is there any e2e spec covering them? Check the e2e test directory for related specs. +4. **Bug-fix-shaped changes** (a conditional tightened, an edge case handled): is there a regression test proving the fix? + +Do NOT flag: +- Trivial or purely presentational changes (styling, copy, layout) +- Code that is impractical to unit test and already covered transitively by e2e +- Pre-existing untested code not touched by this diff + +Return ONLY a JSON array of findings: [{file, line, category: "test-coverage", severity, claim (what changed without coverage), detail, suggestion (what kind of test to add and where)}] +``` + +### Agent 10: Copy Editor (skip if the copy diff is empty AND the full diff adds no English user-facing strings) + +This agent receives the **copy diff** (the `locales/en/` changes collected in Step 1) instead of the full diff, plus any added English user-facing strings from the full diff (JSX text, aria-labels, error messages, notification texts). It is never sharded. + +``` +You are proofreading English texts that will be shown to users of sendou.ink, a competitive Splatoon community website. + +New/changed English strings from translation files (locales/en/*.json): +{copy_diff} + +New English user-facing strings found in code (if any): +{code_strings} + +Only review ADDED or CHANGED strings, not pre-existing ones. Check each for: +- Typos and misspellings +- Grammar mistakes (subject-verb agreement, articles, tense) +- Broken i18next interpolation: mismatched or misspelled `{{variable}}` placeholders, wrong plural key forms — compare against how the key is used in the code if unsure +- Unclear or confusing phrasing — would a user reading this out of context understand what to do? +- Unnecessarily technical or jargon-y wording where plainer language would be friendlier (e.g. "Invalid input" vs telling the user what to fix) +- Inconsistent terminology or capitalization vs the rest of the site — read the existing `locales/en/*.json` files to learn the established terms (e.g. how features like SendouQ, scrims, tournaments are referred to) and match them + +Severity: typos, grammar mistakes, and broken interpolation are "warning". Clarity/friendliness rewording is "info". + +Do NOT flag: +- Established community/game terminology that only looks unusual (Splatoon terms, ability names, mode names) +- Intentionally short labels where brevity fits the UI +- Style preferences that are a wash — only suggest a rewording if it is clearly better + +Return ONLY a JSON array of findings: [{file, line, category: "copy", severity, claim (the problematic text, quoted), detail, suggestion (the corrected/improved text, quoted)}] +``` + +## Step 5: Adversarial verification + +Collect all findings from the finder agents into one pool (parse their JSON outputs). **Do not verify** mechanical pre-check findings from Step 2 (already confirmed by tooling), db-performance findings that include measured EXPLAIN/COUNT evidence (already confirmed by measurement), or copy findings (proofreading is judgement, not a falsifiable claim — the summarizer filters these instead). Everything else gets verified. + +For each remaining finding, launch a verifier agent. Run them in parallel. If there are more than ~12 findings, batch them by file so each verifier handles 2-4 findings from the same file. Hard ceiling: **at most 10 verifier agent calls** — with more findings than fits, grow the batch size rather than the agent count (a verifier can handle up to ~6 findings if they share files). + +``` +You are a skeptical senior engineer. Your job is to REFUTE the following code review finding(s). Assume each is a false positive until the code proves otherwise. + +Finding(s): +{finding_json} + +Relevant diff hunks: +{diff_excerpt} + +Read the actual files involved. For each finding, check: +- Does the claimed problem actually exist in the code as written? +- Is it prevented by the surrounding code, the type system, validation upstream, or the framework? +- For bugs: can you trace a concrete input/state that triggers it? +- For abstraction findings: does the cited existing code actually fit this use case? +- For spec findings: does the code actually diverge from the spec, or did the finder misread one of them? +- Is the finding about NEW code in the diff, or a pre-existing issue? + +If uncertain, default to refuted — a review's value depends on zero false positives. + +Return ONLY a JSON array, one entry per finding: [{claim: "", verdict: "confirmed" | "refuted", reason: "1-2 sentences"}] +``` + +Discard refuted findings. Confirmed findings (plus the exempted pre-checked/measured ones) proceed to the summarizer. + +## Step 6: Summarize + +Launch a single summarizer agent that receives all surviving findings. + +``` +You are the final reviewer synthesizing verified code review findings from multiple specialized agents plus mechanical pre-checks. + +Here are the surviving findings (all have passed adversarial verification, mechanical checking, or direct measurement): + +{surviving_findings_json} Your job: -1. **Deduplicate**: Multiple agents may flag the same issue from different angles. Merge these into a single finding. -2. **Filter**: Remove low-quality suggestions that are: - - Nitpicks that wouldn't matter in practice - - False positives or theoretical issues unlikely to occur - - Suggestions that would make the code worse or more complex - - Pre-existing issues not introduced by the diff -3. **Prioritize** using this order: Security > Bugs > DB Query Performance (critical/warning only) > Spec Violations > Abstraction Issues > CLAUDE.md Violations > Modernization Suggestions > DB Query Performance (info) -4. **Format** the output as a single cohesive review +1. **Deduplicate**: Multiple agents may flag the same issue from different angles. Merge these into a single finding, keeping the best explanation. +2. **Filter**: Even verified findings can be nitpicks. Remove suggestions that wouldn't matter in practice or would make the code worse or more complex. +3. **Prioritize** using this order: Typecheck failures > Security > Bugs > DB Query Performance (critical/warning only) > Spec Violations > Test Coverage > Abstraction Issues > CLAUDE.md Violations > Copy (warning: typos/grammar/broken interpolation) > Modernization Suggestions > Copy (info: clarity rewordings) > DB Query Performance (info) -Output format: +Return ONLY a JSON array of the final findings, in priority order, each shaped: {file, line, category, severity, claim, detail, suggestion} +``` + +## Step 7: Report + +Report the summarizer's final findings with the **ReportFindings tool** (you, the main agent, call it — not a subagent): one call, findings ranked most-severe first, each with `file`, `line`, `summary` (claim + suggestion), `short_summary`, `category`, `failure_scenario` (from detail), and `verdict: "CONFIRMED"` for findings that passed verification or measurement, `"PLAUSIBLE"` otherwise. Do not also print the findings list as text — after the ReportFindings call, write only a short prose wrap-up (issue count, overall shape of the review, anything notable like sharding or skipped agents). + +If the ReportFindings tool is not available in the session, fall back to printing this format instead: ### Code Review **{N} issues found** (or "No issues found — looks good!" if none survive filtering) -For each surviving issue, in priority order: +For each finding, in priority order: **{priority_number}. [{category}] {brief title}** -`{file_path}` +`{file_path}:{line}` {description — 1-3 sentences explaining the problem and a concrete suggestion} --- -At the end, if there were modernization suggestions that survived filtering, group them under a separate "Suggestions" section (these are nice-to-haves, not blockers). -``` +At the end, group surviving modernization suggestions and info-level copy rewordings under a separate "Suggestions" section (these are nice-to-haves, not blockers). ## Important notes -- Use `subagent_type: "Explore"` for Agent 5 (Abstraction Police) since it needs to search the codebase broadly -- Use `subagent_type: "general-purpose"` for Agent 7 (DB Query Performance) since it needs to run sqlite3 commands via Bash -- Use `subagent_type: "general-purpose"` for the other agents -- Use `model: "sonnet"` for agents 1-7 and `model: "opus"` for the summarizer +- Use `subagent_type: "Explore"` for Agent 6 (Abstraction Police) since it needs to search the codebase broadly +- Use `subagent_type: "general-purpose"` for Agent 8 (DB Query Performance) since it needs to run sqlite3 commands and vite-node scripts via Bash +- Use `subagent_type: "general-purpose"` for the other finder agents, the verifiers, and the summarizer +- Use `model: "sonnet"` for finder and verifier agents and `model: "opus"` for the summarizer - Pass the actual diff content and file list to each agent — do not tell them to run git commands themselves -- If the diff is very large (>2000 lines), mention this to the user and note that the review may miss some issues -- Present the summarizer's output directly to the user as the final review +- If a finder agent returns malformed JSON, salvage what you can by reading its output; do not re-run it +- Present the review via ReportFindings as described in Step 7 diff --git a/.claude/skills/sql/SKILL.md b/.claude/skills/sql/SKILL.md index 79c0cc0f3..f15b6c606 100644 --- a/.claude/skills/sql/SKILL.md +++ b/.claude/skills/sql/SKILL.md @@ -11,7 +11,7 @@ description: Help write, debug, and explore SQLite database queries using Kysely |------|---------|-------------| | `db.sqlite3` | Development database | Writing/testing new features, running the dev server | | `db-prod.sqlite3` | Copy of production database | Exploring real data, investigating bugs, verifying assumptions about data shape | -| `db-test.sqlite3` | Unit test database (blank + migrations) | Unit tests only | +| `db-test.sqlite3` | Unit test database (blank + migrations, created automatically by test runs) | Unit tests only | `db-prod.sqlite3` can be modified if the task requires it. **Never modify `db-copy.sqlite3`** — it is the untouched backup. diff --git a/.env.example b/.env.example index 4d2d24602..1ae987ffc 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,12 @@ VITE_STATIC_ASSETS_URL=https://sendou-assets.nyc3.cdn.digitaloceanspaces.com TWITCH_CLIENT_ID= TWITCH_CLIENT_SECRET= +// Discord webhook mod events (user reports, bans) are posted to (skipped when unset) +MOD_DISCORD_WEBHOOK_URL= + +// Discord webhook SendouQ match cancellations are posted to (skipped when unset) +SQ_CANCEL_DISCORD_WEBHOOK_URL= + SKALOP_SYSTEM_MESSAGE_URL=http://localhost:5900/system SKALOP_TOKEN=secret REDIS_URL=redis://redis:6379 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index efdfd1a16..4e18b68e8 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -23,10 +23,13 @@ jobs: - name: Start MinIO run: docker compose up -d minio + # the bucket, not the health endpoint: MinIO answers health/live while it is still + # bootstrapping, so a run whose bucket never got created would only surface much later + # as a 500 in the one test that uploads an image - name: Wait for MinIO to be ready run: | for i in {1..30}; do - if curl -sf http://127.0.0.1:9000/minio/health/live; then + if curl -sf -o /dev/null http://127.0.0.1:9000/sendou/; then echo "MinIO is ready" exit 0 fi diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d9f6ae94b..db353ae80 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -40,5 +40,3 @@ jobs: run: pnpm run check-plural-collapse - name: Check articles run: pnpm run check-articles - - name: Check test DB migrations - run: pnpm run check-test-db-migrations diff --git a/.gitignore b/.gitignore index d1be54a5d..d7215323d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,8 +11,6 @@ translation-progress.md notes.md db*.sqlite3* -!db-test.sqlite3 -!e2e/seeds/*.sqlite3 dump .DS_Store @@ -33,6 +31,7 @@ dump # Real baselines have pattern: *-chromium-darwin.png **/__screenshots__/**/*-[0-9].png .e2e-minio-started +.e2e-build-marker notepad.txt diff --git a/AGENTS.md b/AGENTS.md index 8f5d010e0..c9248738e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,12 @@ - new routes need to be added to `routes.ts` +## Search params + +- all URL search param handling goes through `app/modules/search-params/`, see [search-params.md](./docs/dev/search-params.md) for the conventions +- never use raw `useSearchParams` or `searchParams.get()`; declare params once per feature in a `-search-params.ts` definition (every param has a default, decode never fails). Enforced by the `no-raw-search-params` Biome plugin +- every definition gets a round-trip test via `assertRoundTrips` + ## Styling - use CSS modules @@ -57,14 +63,15 @@ ## SQL -- database is Sqlite3 used with the Kysely library -- database code should only be written in Repository files +- database is Sqlite3, driven by Node's built-in `node:sqlite` through a custom Kysely dialect (`app/db/node-sqlite-dialect.ts`) +- database code should only be written in Repository files, see [repositories.md](./docs/dev/repositories.md) for their conventions +- migrations are Kysely migrations in `/migrations`, scaffolded with `pnpm run migrate:new "description"` and applied with `pnpm run migrate up`, see [how-to.md](./docs/dev/how-to.md) - down migrations are not needed, only up migrations - every database id is of type number - if we are working on a branch by default we should add to the migration this branch added instead of creating a brand new one -- `/app/db/tables.ts` contains all tables and columns available +- `/app/db/tables.ts` contains all tables and columns available, see [database-schemas.md](./docs/dev/database-schemas.md) for how columns should be typed (booleans, timestamps, JSON, enums, SQLite migration quirks) - `db.sqlite3` is development database -- `db-test.sqlite3` is the unit test database (should be blank sans migrations ran) +- `db-test.sqlite3` is the unit test database (blank sans migrations; gitignored and created/migrated automatically when unit tests run) - `db-prod.sqlite3` is a copy of the production environment db which can be freely experimented with ## Unit testing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9eba6d4e9..067be3c82 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,7 @@ # Contributing +Thank you for taking interest in contributing to sendou.ink! Please note that mainly AI driven contributions from people who are not in the community or using the project are not wanted and will not be considered. + ## How to contribute Reading the [architecture.md](./docs/dev/architecture.md) file is highly recommended before writing any code to get up to the speed with how the project folder structure works and getting familiar with its concepts. @@ -16,14 +18,14 @@ You can also just directly make a pull request but untracked feature requests mi ## Making pull request -1. Identify an issue to work on +First of all note that a well detailed issue that explains a problem from user point of view with clear examples and code level investigation is often more valuable than a pull request. -- Issues I have identified as potentially good for external contributors [are marked with the help wanted tag.](https://github.com/Sendouc/sendou.ink/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) -- "good first issue" are tasks that might be smaller in scope and easier to get started with +When making a pull request what makes it easy to review (which makes it likely to be processed faster) include following: -2. Make pull request - -- Tell me how I can test it and include unit / E2E tests if possible. +1) Change is pre-discussed in an issue or GitHub, direction agreed upon +2) If many unrelated changes are made, they are opened as separate pull requests (smaller is better generally speaking) +3) UI changes are showcased with screenshots before/after +4) Appropriate automated tests exist ## Help diff --git a/app/components/Ability.tsx b/app/components/Ability.tsx index ffdc45b4b..7f997487f 100644 --- a/app/components/Ability.tsx +++ b/app/components/Ability.tsx @@ -7,6 +7,7 @@ import styles from "./Ability.module.css"; import { Image } from "./Image"; const sizeMap = { + HUGE: 64, MAIN: 42, SUB: 32, SUBTINY: 26, diff --git a/app/components/Avatar.tsx b/app/components/Avatar.tsx index 2634bd0eb..8ae9989dd 100644 --- a/app/components/Avatar.tsx +++ b/app/components/Avatar.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import type { Tables } from "~/db/tables"; import { useHydrated } from "~/hooks/useHydrated"; import { LRUCache } from "~/modules/cache"; -import { BLANK_IMAGE_URL, discordAvatarUrl } from "~/utils/urls"; +import { BLANK_IMAGE_URL, resolveAvatarUrl } from "~/utils/urls"; import styles from "./Avatar.module.css"; const dimensions = { @@ -132,19 +132,23 @@ export function Avatar({ const identiconSource = identiconInput ?? user?.discordId ?? "unknown"; - const src = url - ? url - : user?.customAvatarUrl && !isErrored - ? user.customAvatarUrl - : user?.discordAvatar && !isErrored - ? discordAvatarUrl({ - discordAvatar: user.discordAvatar, - discordId: user.discordId, - size: size === "lg" || size === "xmd" ? "lg" : "sm", - }) - : isClient - ? generateIdenticon(identiconSource, dimensions[size], 7) - : BLANK_IMAGE_URL; + const userAvatarUrl = user + ? resolveAvatarUrl({ + customAvatarUrl: user.customAvatarUrl, + discordId: user.discordId, + discordAvatar: user.discordAvatar, + size: size === "lg" || size === "xmd" ? "lg" : "sm", + }) + : undefined; + + const avatarUrl = url ?? userAvatarUrl; + + const src = + avatarUrl && !isErrored + ? avatarUrl + : isClient + ? generateIdenticon(identiconSource, dimensions[size], 7) + : BLANK_IMAGE_URL; return (
diff --git a/app/components/BuildCard.tsx b/app/components/BuildCard.tsx index 3296c1485..0bdb5b892 100644 --- a/app/components/BuildCard.tsx +++ b/app/components/BuildCard.tsx @@ -1,16 +1,30 @@ import clsx from "clsx"; -import { Lock, MessageCircleMore, SquarePen, Trash } from "lucide-react"; +import { + HardDriveDownload, + Lock, + MessageCircleMore, + SquarePen, + Trash, +} from "lucide-react"; +import * as React from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import type { GearType, Tables, UserWithPlusTier } from "~/db/tables"; +import type { Tables } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types"; +import { + BuildGraphic, + type BuildGraphicOwner, +} from "~/features/img-export/components/BuildGraphic"; +import { ImageExportDialog } from "~/features/img-export/components/ImageExportDialog"; import type { Ability as AbilityType, BuildAbilitiesTuple, + GearType, ModeShort, } from "~/modules/in-game-lists/types"; import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids"; +import type { UserWithPlusTier } from "~/utils/kysely.server"; import { gearTypeToInitial } from "~/utils/strings"; import { analyzerPage, @@ -26,6 +40,7 @@ import { Ability } from "./Ability"; import styles from "./BuildCard.module.css"; import { LinkButton, SendouButton } from "./elements/Button"; import { SendouPopover } from "./elements/Popover"; +import { SendouSwitch } from "./elements/Switch"; import { FormWithConfirm } from "./FormWithConfirm"; import { Image } from "./Image"; import { LocaleTime } from "./LocaleTime"; @@ -40,17 +55,27 @@ interface BuildProps { | "headGearSplId" | "shoesGearSplId" | "updatedAt" - | "private" + | "isPrivate" > & { abilities: BuildAbilitiesTuple; modes: ModeShort[] | null; weapons: Array; }; - owner?: Pick; + owner?: Pick & + Partial< + Pick + >; + /** Set to false when the page context already shows the owner (e.g. their own builds page) */ + showOwner?: boolean; canEdit?: boolean; } -export function BuildCard({ build, owner, canEdit = false }: BuildProps) { +export function BuildCard({ + build, + owner, + showOwner = true, + canEdit = false, +}: BuildProps) { const user = useUser(); const { t } = useTranslation(["weapons", "builds", "common", "game-misc"]); @@ -73,7 +98,7 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) { return (
@@ -98,22 +123,22 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
- {owner && ( + {owner && showOwner ? ( <> {owner.username}
- )} - {owner?.plusTier ? ( + ) : null} + {owner?.plusTier && showOwner ? ( <> +{owner.plusTier}
) : null}
- {build.private ? ( + {build.isPrivate ? (
{t("common:build.private")}
@@ -178,6 +203,7 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) { path={navIconUrl("analyzer")} /> + {owner ? : null} {description ? ( } + className={styles.smallText} + aria-label={t("common:imageExport.export")} + /> + } + heading={t("common:imageExport.export")} + filename={`build-${mySlugify(build.title)}`} + qrCodePath={analyzerPage({ + weaponId: build.weapons[0].weaponSplId, + abilities: build.abilities.flat(), + })} + settings={ + <> + + {t("common:imageExport.buildTitle")} + + + {t("common:imageExport.abilityPoints")} + + + {t("common:imageExport.abilityChunks")} + + + } + > + + + ); +} + function RoundWeaponImage({ weapon }: { weapon: BuildWeaponWithTop500Info }) { const normalizedWeaponSplId = canonicalWeaponSplId(weapon.weaponSplId); diff --git a/app/components/Catcher.tsx b/app/components/Catcher.tsx index a44da4f74..03798e37d 100644 --- a/app/components/Catcher.tsx +++ b/app/components/Catcher.tsx @@ -45,7 +45,7 @@ export function Catcher() { if (isNetworkError) { return ( -
+

Connection error

@@ -55,7 +55,7 @@ export function Catcher() {

-
+ ); } @@ -68,7 +68,7 @@ export function Catcher() { })(); return ( -
+

Error happened

@@ -84,7 +84,7 @@ export function Catcher() {

) : null} - + ); } @@ -92,7 +92,7 @@ export function Catcher() { case 401: if (!user) { return ( -
+

Authentication required

This page requires you to be logged in.

@@ -100,36 +100,36 @@ export function Catcher() { Log in via Discord -
+ ); } return ( -
+

Error 401 Unauthorized

-
+ ); case 403: return ( -
+

Error 403 Forbidden

Your account doesn't have the required permissions to perform this action.

-
+ ); case 404: return ( -
+

Error {error.status} - Page not found

-
+ ); default: return ( -
+

Error {error.status}

@@ -142,11 +142,16 @@ export function Catcher() { ? `\n${typeof error.data === "string" ? error.data : JSON.stringify(error.data, null, 2)}` : null} -
+ ); } } +/** Every branch of the error page, marked so tests can assert one is not shown. */ +function ErrorMain({ children }: { children: React.ReactNode }) { + return
{children}
; +} + function GetHelp() { return (

diff --git a/app/components/Chart.tsx b/app/components/Chart.tsx index de156b93c..ef10ecc99 100644 --- a/app/components/Chart.tsx +++ b/app/components/Chart.tsx @@ -13,6 +13,7 @@ import { useRef } from "react"; import { Line } from "react-chartjs-2"; import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import { useHydrated } from "~/hooks/useHydrated"; +import { useThemeColors } from "~/hooks/useThemeColors"; import styles from "./Chart.module.css"; ChartJS.register( @@ -87,43 +88,19 @@ export default function Chart({ }); // Get the chart colors from CSS variables - const [colors, setColors] = React.useState({ - accentHigh: "", - infoHigh: "", - secondHigh: "", - accentLow: "", - secondLow: "", - border: "", - borderHigh: "", - text: "", + const colors = useThemeColors({ + // bright "high" variants for the curve lines so they stay legible on the dark chart + accentHigh: "--color-text-accent", + infoHigh: "--color-info-high", + secondHigh: "--color-second-high", + // low variants for the highlight marker fills (paired with a light border) + accentLow: "--color-accent-low", + secondLow: "--color-second-low", + border: "--color-border", + borderHigh: "--color-border-high", + text: "--color-text-high", }); - React.useEffect(() => { - const resolve = () => { - const get = (v: string) => - getComputedStyle(document.documentElement).getPropertyValue(v).trim(); - setColors({ - // bright "high" variants for the curve lines so they stay legible on the dark chart - accentHigh: get("--color-text-accent"), - infoHigh: get("--color-info-high"), - secondHigh: get("--color-second-high"), - // low variants for the highlight marker fills (paired with a light border) - accentLow: get("--color-accent-low"), - secondLow: get("--color-second-low"), - border: get("--color-border"), - borderHigh: get("--color-border-high"), - text: get("--color-text-high"), - }); - }; - - resolve(); - - const root = document.documentElement; - const observer = new MutationObserver(resolve); - observer.observe(root, { attributes: true, attributeFilter: ["class"] }); - return () => observer.disconnect(); - }, []); - const scaleDefaults = React.useMemo( () => ({ grid: { color: colors.border }, diff --git a/app/components/CustomThemeSelector.tsx b/app/components/CustomThemeSelector.tsx index 9e2560f4c..8b27dbea5 100644 --- a/app/components/CustomThemeSelector.tsx +++ b/app/components/CustomThemeSelector.tsx @@ -1,11 +1,11 @@ import { Check, Clipboard, PencilLine } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; +import type { CustomTheme } from "~/db/tables-json"; import { CUSTOM_THEME_VARS, - type CustomTheme, type CustomThemeVar, -} from "~/db/tables"; +} from "~/features/theme/theme-constants"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { ACCENT_CHROMA_MULTIPLIERS, diff --git a/app/components/DotPagination.module.css b/app/components/DotPagination.module.css new file mode 100644 index 000000000..b07544875 --- /dev/null +++ b/app/components/DotPagination.module.css @@ -0,0 +1,27 @@ +.pagination { + display: flex; + flex-wrap: wrap; + gap: var(--s-3); + justify-content: center; + align-items: center; + max-width: 20rem; + margin: 0 auto; + margin-top: var(--s-2); +} + +.button { + background-color: var(--color-bg); + border-radius: 100%; + padding: var(--s-1); + height: 24px; + width: 24px; + border: var(--border-style); + font-size: var(--font-xs); + color: var(--color-text-high); +} + +.buttonActive { + color: var(--color-text-accent); + background-color: var(--color-bg-high); + border-color: var(--color-border-high); +} diff --git a/app/components/DotPagination.tsx b/app/components/DotPagination.tsx new file mode 100644 index 000000000..9cc4563a8 --- /dev/null +++ b/app/components/DotPagination.tsx @@ -0,0 +1,38 @@ +import { clsx } from "clsx"; +import { SendouButton } from "~/components/elements/Button"; +import styles from "./DotPagination.module.css"; + +export function DotPagination({ + pagesCount, + currentPage, + setPage, + ariaLabelPrefix, + "data-testid": testId, + className, +}: { + pagesCount: number; + currentPage: number; + setPage: (page: number) => void; + ariaLabelPrefix: string; + "data-testid"?: string; + className?: string; +}) { + return ( +

+ {Array.from({ length: pagesCount }, (_, i) => ( + setPage(i + 1)} + className={clsx(styles.button, { + [styles.buttonActive]: currentPage === i + 1, + })} + data-testid={testId} + > + {i + 1} + + ))} +
+ ); +} diff --git a/app/components/EventsList.tsx b/app/components/EventsList.tsx index 6ada6f1aa..c7e63686a 100644 --- a/app/components/EventsList.tsx +++ b/app/components/EventsList.tsx @@ -63,7 +63,7 @@ export function EventsList({ const groupedEvents = events.reduce>( (acc, event) => { - const key = getDayKey(event.startTime); + const key = getDayKey(event.startsAt); if (!acc[key]) { acc[key] = []; } @@ -79,7 +79,7 @@ export function EventsList({ <> {dayKeys.map((dayKey) => { const dayEvents = groupedEvents[dayKey]; - const firstDate = new Date(dayEvents[0].startTime * 1000); + const firstDate = new Date(dayEvents[0].startsAt * 1000); return (
@@ -89,7 +89,8 @@ export function EventsList({ key={`${event.type}-${event.id}`} to={event.url} imageUrl={event.logoUrl ?? undefined} - subtitle={timeFormatter.format(event.startTime)} + user={event.user ?? undefined} + subtitle={timeFormatter.format(event.startsAt)} onClick={onClick} > {event.scrimStatus === "booked" diff --git a/app/components/Flag.tsx b/app/components/Flag.tsx index 9dbcf91c6..897691eeb 100644 --- a/app/components/Flag.tsx +++ b/app/components/Flag.tsx @@ -12,7 +12,7 @@ export function Flag({ const { i18n } = useTranslation(); return ( -
{ + const openDialog = () => { onOpenChange?.(true); setInternalOpen(true); - }, [onOpenChange]); - const closeDialog = React.useCallback(() => { + }; + const closeDialog = () => { onOpenChange?.(false); setInternalOpen(false); - }, [onOpenChange]); + }; invariant(!children || React.isValidElement(children)); - React.useEffect(() => { - if (fetcher.state === "loading") { - closeDialog(); - } - }, [fetcher.state, closeDialog]); - return ( <> {isHydrated @@ -84,6 +78,7 @@ export function FormWithConfirm({ ref={formRef} method="post" action={action} + onSubmit={closeDialog} > {fields?.map(([name, value]) => ( diff --git a/app/components/FriendCodeInput.tsx b/app/components/FriendCodeInput.tsx index 7b49ed8b5..5ec7aff81 100644 --- a/app/components/FriendCodeInput.tsx +++ b/app/components/FriendCodeInput.tsx @@ -1,13 +1,8 @@ -import clsx from "clsx"; -import * as React from "react"; import { useTranslation } from "react-i18next"; -import { useFetcher } from "react-router"; import { Image } from "~/components/Image"; import { InfoPopover } from "~/components/InfoPopover"; -import { Input } from "~/components/Input"; -import { Label } from "~/components/Label"; -import { SubmitButton } from "~/components/SubmitButton"; -import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants"; +import { addFriendCodeSchema } from "~/features/sendouq/q-schemas"; +import { SendouForm } from "~/form/SendouForm"; import { navIconUrl, SENDOUQ_PAGE } from "~/utils/urls"; const FC_INFO_IMAGE_URL = navIconUrl("fc-info"); @@ -17,60 +12,39 @@ export function FriendCodeInput({ }: { friendCode?: string | null; }) { - const fetcher = useFetcher(); const { t } = useTranslation(["common"]); - const id = React.useId(); + + if (friendCode) { + return
SW-{friendCode}
; + } return ( - - -
-
- {!friendCode ? ( -
- - -
-
- {t("common:fc.whereToFind")} -
- + + {({ FormField }) => ( +
+ +
+ {t("common:fc.onceSetStaffOnly")} + +
+
+ {t("common:fc.whereToFind")}
- -
- ) : null} - {friendCode ? ( -
SW-{friendCode}
- ) : ( - - )} + +
+ +
- {!friendCode ? ( - - {t("common:actions.save")} - - ) : null} -
- {!friendCode ? ( -
- {t("common:fc.onceSetStaffOnly")} -
- ) : null} - + )} + ); } diff --git a/app/components/GearSelect.tsx b/app/components/GearSelect.tsx index 1b1ad6763..472f19d46 100644 --- a/app/components/GearSelect.tsx +++ b/app/components/GearSelect.tsx @@ -5,13 +5,13 @@ import { SendouSelectItemSection, } from "~/components/elements/Select"; import { Image } from "~/components/Image"; -import type { GearType } from "~/db/tables"; import { brandIds } from "~/modules/in-game-lists/brand-ids"; import { clothesGearBrandGrouped, headGearBrandGrouped, shoesGearBrandGrouped, } from "~/modules/in-game-lists/gear-ids"; +import type { GearType } from "~/modules/in-game-lists/types"; import { brandImageUrl, gearImageUrl } from "~/utils/urls"; import styles from "./WeaponSelect.module.css"; diff --git a/app/components/Main.tsx b/app/components/Main.tsx index 91eeb3eba..a6248c23d 100644 --- a/app/components/Main.tsx +++ b/app/components/Main.tsx @@ -10,6 +10,7 @@ export const Main = ({ bigger, breakoutContainer, style, + testId, }: { children: React.ReactNode; className?: string; @@ -18,6 +19,7 @@ export const Main = ({ bigger?: boolean; breakoutContainer?: boolean; style?: React.CSSProperties; + testId?: string; }) => { return (
{children} diff --git a/app/components/MobileNav.tsx b/app/components/MobileNav.tsx index 207a0a1e5..3193b2891 100644 --- a/app/components/MobileNav.tsx +++ b/app/components/MobileNav.tsx @@ -15,11 +15,12 @@ import { import * as React from "react"; import { Dialog, Modal, ModalOverlay } from "react-aria-components"; import { useTranslation } from "react-i18next"; -import { Link } from "react-router"; +import { Link, useLocation } from "react-router"; import { useUser } from "~/features/auth/core/user"; import { useChatContext } from "~/features/chat/useChatContext"; import { FriendMenu } from "~/features/friends/components/FriendMenu"; import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants"; +import { canAccessTrophies } from "~/features/trophies/trophies-utils"; import { useLayoutSize } from "~/hooks/useMainContentWidth"; import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests"; import type { RootLoaderData } from "~/root"; @@ -27,6 +28,7 @@ import { EVENTS_PAGE, FRIENDS_PAGE, navIconUrl, + SENDOU_INK_BASE_URL, SETTINGS_PAGE, SUPPORT_PAGE, userPage, @@ -44,6 +46,7 @@ import { import { navItems } from "./layout/nav-items"; import styles from "./MobileNav.module.css"; import { NotificationDot } from "./NotificationDot"; +import { ShareUrlButton } from "./ShareUrlButton"; import { StreamListItems } from "./StreamListItems"; type SidebarData = RootLoaderData["sidebar"] | undefined; @@ -346,6 +349,7 @@ function MenuOverlay({ }) { const { t } = useTranslation(["front", "common"]); const user = useUser(); + const location = useLocation(); return ( ) : null} + ))} + {DEV_LINK_ITEMS.map((item) => ( + { + setIsOpen(false); + setIsPreviewSuppressed(true); + }} + > + + {item.name} + + ))}
{!isOpen && !isPreviewSuppressed ? ( @@ -149,6 +173,19 @@ function DevMenu() { ))} + {DEV_LINK_ITEMS.map((item) => ( + setIsPreviewSuppressed(true)} + > + + + ))}
) : null}
@@ -167,9 +204,11 @@ function CategoryMenu({ const isStaff = user?.roles.includes("STAFF") ?? false; const showStaffOnly = isStaff || process.env.NODE_ENV === "development"; - const visibleItems = category.items.filter( - (item) => !("staffOnly" in item) || showStaffOnly, - ); + const visibleItems = category.items.filter((item) => { + if ("staffOnly" in item && !showStaffOnly) return false; + if (item.name === "trophies" && !canAccessTrophies(user)) return false; + return true; + }); return (
diff --git a/app/components/layout/WeaponSearch.tsx b/app/components/layout/WeaponSearch.tsx index 4fb4ae506..6bc199fff 100644 --- a/app/components/layout/WeaponSearch.tsx +++ b/app/components/layout/WeaponSearch.tsx @@ -11,7 +11,7 @@ import { Users, Videotape, } from "lucide-react"; -import type * as React from "react"; +import * as React from "react"; import { ListBox, ListBoxItem } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { Image } from "~/components/Image"; @@ -311,12 +311,28 @@ export function WeaponResultsList({ const RECENT_WEAPONS_KEY = "command-palette-recent-weapons"; const MAX_RECENT_WEAPONS = 5; -export function getRecentWeapons(): MainWeaponId[] { - if (typeof window === "undefined") return []; +const recentWeaponsListeners = new Set<() => void>(); + +function subscribeRecentWeapons(listener: () => void) { + recentWeaponsListeners.add(listener); + window.addEventListener("storage", listener); + return () => { + recentWeaponsListeners.delete(listener); + window.removeEventListener("storage", listener); + }; +} + +function getRecentWeaponsSnapshot() { try { - const stored = localStorage.getItem(RECENT_WEAPONS_KEY); - if (!stored) return []; - const parsed = JSON.parse(stored); + return localStorage.getItem(RECENT_WEAPONS_KEY) ?? "[]"; + } catch { + return "[]"; + } +} + +function parseRecentWeapons(raw: string): MainWeaponId[] { + try { + const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed.filter( (id): id is MainWeaponId => @@ -327,13 +343,25 @@ export function getRecentWeapons(): MainWeaponId[] { } } +export function useRecentWeapons(): MainWeaponId[] { + const raw = React.useSyncExternalStore( + subscribeRecentWeapons, + getRecentWeaponsSnapshot, + () => "[]", + ); + return parseRecentWeapons(raw); +} + export function saveRecentWeapon(weaponId: MainWeaponId): void { try { - const recent = getRecentWeapons(); + const recent = parseRecentWeapons(getRecentWeaponsSnapshot()); const filtered = recent.filter((id) => id !== weaponId); const updated = [weaponId, ...filtered].slice(0, MAX_RECENT_WEAPONS); localStorage.setItem(RECENT_WEAPONS_KEY, JSON.stringify(updated)); } catch { // localStorage may be unavailable } + for (const listener of recentWeaponsListeners) { + listener(); + } } diff --git a/app/components/layout/global-search-search-params.test.ts b/app/components/layout/global-search-search-params.test.ts new file mode 100644 index 000000000..c17ef64a9 --- /dev/null +++ b/app/components/layout/global-search-search-params.test.ts @@ -0,0 +1,25 @@ +import { describe, it } from "vitest"; +import { + assertDecodesToDefault, + assertRoundTrips, +} from "~/modules/search-params/search-params-test-utils"; +import { globalSearchSearchParams } from "./global-search-search-params"; + +describe("globalSearchSearchParams", () => { + it("round-trips", () => { + assertRoundTrips(globalSearchSearchParams, { + search: [null, "open"], + type: [null, "weapons", "users", "teams", "organizations", "tournaments"], + weapon: [null, 0, 10, 8000], + }); + }); + + it("malformed values decode to defaults", () => { + assertDecodesToDefault(globalSearchSearchParams, "search", [["closed"]]); + assertDecodesToDefault(globalSearchSearchParams, "type", [["USER"]]); + assertDecodesToDefault(globalSearchSearchParams, "weapon", [ + ["99999"], + ["abc"], + ]); + }); +}); diff --git a/app/components/layout/global-search-search-params.ts b/app/components/layout/global-search-search-params.ts new file mode 100644 index 000000000..16b04f6a8 --- /dev/null +++ b/app/components/layout/global-search-search-params.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; +import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; +import * as SearchParams from "~/modules/search-params/search-params"; +import { SP } from "~/modules/search-params/search-params"; +import { numericEnum } from "~/utils/zod"; + +export const GLOBAL_SEARCH_TYPES = [ + "weapons", + "users", + "teams", + "organizations", + "tournaments", +] as const; +export type GlobalSearchType = (typeof GLOBAL_SEARCH_TYPES)[number]; + +export const globalSearchSearchParams = SearchParams.define({ + search: SP.param(z.enum(["open"]).nullable(), { loader: false }), + type: SP.param(z.enum(GLOBAL_SEARCH_TYPES).nullable(), { loader: false }), + weapon: SP.param(numericEnum(mainWeaponIds).nullable(), { loader: false }), +}); diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx index b8dee259b..2dfe125b0 100644 --- a/app/components/layout/index.tsx +++ b/app/components/layout/index.tsx @@ -31,6 +31,7 @@ import { useLayoutSize } from "~/hooks/useMainContentWidth"; import { usePrefersReducedMotion } from "~/hooks/usePrefersReducedMotion"; import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests"; import { useVisualViewportHeight } from "~/hooks/useVisualViewportHeight"; +import { useSearchParam } from "~/modules/search-params/hooks"; import type { RootLoaderData } from "~/root"; import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server"; import { @@ -50,11 +51,11 @@ import { NotificationDot } from "../NotificationDot"; import { ListLink, SideNav, SideNavFooter, SideNavHeader } from "../SideNav"; import sideNavStyles from "../SideNav.module.css"; import { StreamListItems } from "../StreamListItems"; -import { AuthErrorDialog } from "./AuthErrorDialog"; import { ChatSidebar } from "./ChatSidebar"; import { Footer } from "./Footer"; import styles from "./index.module.css"; import { LogInButtonContainer } from "./LogInButtonContainer"; +import { authErrorSearchParams } from "./layout-search-params"; import { NotificationContent, useNotifications } from "./NotificationPopover"; import notificationPopoverStyles from "./NotificationPopover.module.css"; import { TopNavMenus } from "./TopNavMenus"; @@ -62,6 +63,14 @@ import { TopRightButtons } from "./TopRightButtons"; const MAX_DESKTOP_FRIENDS = 4; +// lazy loaded so the rarely needed auth error dialog stays out of the eager +// bundle loaded on every page +const AuthErrorDialog = React.lazy(() => + import("./AuthErrorDialog").then((module) => ({ + default: module.AuthErrorDialog, + })), +); + /** Id of the loading-bar track rendered inside the header. NProgress mounts its * bar into it; the track sits just below the header border, spans only the area * between the sidebars, and clips the bar so it never extends over a sidebar. @@ -240,25 +249,29 @@ export function Layout({ const { formatRelativeDate } = useRelativeDayFormat(); const isHydrated = useHydrated(); const location = useLocation(); + const [authError] = useSearchParam(authErrorSearchParams, "authError"); const headerRef = React.useRef(null); const navOffset = useNavOffset(headerRef); - React.useEffect(() => { - const handleResize = () => { - if (window.innerWidth < 600 || window.innerWidth >= 1000) { - setSideNavModalOpen(false); - setChatSidebarModalOpen(false); - } - }; + // modals only exist in the tablet layout, close them when resizing out of + // it or navigating to another page (setChatOpen is left as is on purpose, + // it belongs to a parent component and thus cannot be set during render) + const prevLayoutSize = React.useRef(layoutSize); + const prevPathname = React.useRef(location.pathname); + const leftTabletLayout = + prevLayoutSize.current === "tablet" && layoutSize !== "tablet"; + const pathnameChanged = prevPathname.current !== location.pathname; + prevLayoutSize.current = layoutSize; + prevPathname.current = location.pathname; - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, []); - - React.useEffect(() => { - setSideNavModalOpen(false); - setChatSidebarModalOpen(false); - }, [location.pathname]); + if (leftTabletLayout || pathnameChanged) { + if (sideNavModalOpen) { + setSideNavModalOpen(false); + } + if (chatSidebarModalOpen) { + setChatSidebarModalOpen(false); + } + } const user = useUser(); const { unseenIds } = useNotifications(); @@ -304,9 +317,10 @@ export function Layout({ key={`${event.type}-${event.id}`} to={event.url} imageUrl={event.logoUrl ?? undefined} + user={event.user ?? undefined} subtitle={ isHydrated ? ( - formatRelativeDate(event.startTime) + formatRelativeDate(event.startsAt) ) : ( Placeholder ) @@ -488,7 +502,11 @@ export function Layout({ setChatSidebarOpen(false)} />
) : null} - + {typeof authError === "string" ? ( + + + + ) : null} ); } diff --git a/app/components/layout/layout-search-params.ts b/app/components/layout/layout-search-params.ts new file mode 100644 index 000000000..09f0ed827 --- /dev/null +++ b/app/components/layout/layout-search-params.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; +import * as SearchParams from "~/modules/search-params/search-params"; +import { SP } from "~/modules/search-params/search-params"; + +export const authErrorSearchParams = SearchParams.define({ + authError: SP.param(z.string().nullable(), { loader: false }), +}); diff --git a/app/components/layout/nav-items.ts b/app/components/layout/nav-items.ts index 5f88b604d..fee7c175e 100644 --- a/app/components/layout/nav-items.ts +++ b/app/components/layout/nav-items.ts @@ -59,8 +59,8 @@ export const navItems = [ prefetch: false, }, { - name: "badges", - url: "badges", + name: "trophies", + url: "trophies", prefetch: false, }, { diff --git a/app/components/match-page/MatchActionTab.module.css b/app/components/match-page/MatchActionTab.module.css index b8b018a77..870054a45 100644 --- a/app/components/match-page/MatchActionTab.module.css +++ b/app/components/match-page/MatchActionTab.module.css @@ -21,7 +21,7 @@ } } -.withPoints { +.withKo { grid-template-areas: "header header header" "actions actions actions" diff --git a/app/components/match-page/MatchActionTab.tsx b/app/components/match-page/MatchActionTab.tsx index 34536f839..fe6702496 100644 --- a/app/components/match-page/MatchActionTab.tsx +++ b/app/components/match-page/MatchActionTab.tsx @@ -39,8 +39,8 @@ interface MatchActionTabProps { ownTeamId: number | null; stageId: StageId; mode: ModeShort; - withPoints: boolean; - onSubmit?: (data: { winnerId: number; points?: [number, number] }) => void; + withKo: boolean; + onSubmit?: (data: { winnerId: number; ko?: boolean }) => void; isSubmitting?: boolean; setEnding?: SetEndingData; actionButtons?: React.ReactNode; @@ -52,7 +52,7 @@ export function MatchActionTab({ ownTeamId, stageId, mode, - withPoints, + withKo, onSubmit, isSubmitting, setEnding, @@ -73,14 +73,7 @@ export function MatchActionTab({ const submit = () => { if (winnerId === null) return; - const submitPoints: [number, number] | undefined = withPoints - ? isKo - ? winnerId === teams[0].id - ? [100, 0] - : [0, 100] - : [0, 0] - : undefined; - onSubmit?.({ winnerId, points: submitPoints }); + onSubmit?.({ winnerId, ko: withKo ? isKo : undefined }); }; return ( @@ -92,14 +85,14 @@ export function MatchActionTab({ mode={mode} winnerId={winnerId} teams={teams} - withPoints={withPoints} + withKo={withKo} isKo={isKo} isSubmitting={isSubmitting} onBack={() => setConfirming(false)} onConfirm={submit} /> ) : ( -
+
{t("q:match.action.selectWinner")}
{actionButtons ? (
{actionButtons}
@@ -156,7 +149,7 @@ export function MatchActionTab({ /> - {withPoints ? ( + {withKo ? (