diff --git a/plan/player-profile.md b/plan/player-profile.md index 6b78b63..c40d259 100644 --- a/plan/player-profile.md +++ b/plan/player-profile.md @@ -15,7 +15,8 @@ There is a page now, at `/profile/` and `/profile/`, and it is the same page for anybody: your own account, an opponent from a lineup, or a handle somebody pasted into a post. What is on it is what can be read without asking us — the identity, and the camo in the player's own repository — plus -your own match count, which is only ever your own. +the record of what they have played, which is at `/matches/` +and is public for the same reason a match report is. It stopped being blocked on [match-records](match-records.md) rather than waiting for it. The page was blocked on the wrong thing: what match records @@ -28,10 +29,11 @@ dash. Those two are the only part of this still waiting. - [ ] **Real Wins and Kills**, from match records. Kills need per-unit attribution, which is `after-action`'s work, so the two tiles land at different times. -- [ ] **Another player's matches.** The tile is deliberately withheld rather - than absent: the list is session-scoped, and a match somebody else - fought is not ours to hand out until it is a record in their own - repository. [match-records](match-records.md), then, and not before. +- [ ] **A card for a player's record.** `/profile/<...>` previews as the + player; `/matches/<...>` previews as the site, because nothing on the + api host draws one. The viewer function already has the shape for it — + the prefix table carries an unfurl target per shell, and this one's is + null. - [ ] **A profile for a player whose matches we never hosted.** The identity and the camo already answer for anybody on the network, because they are read from the network; the match history is what needs @@ -156,6 +158,28 @@ now, mounted beside the account's and waiting on nothing. today and a list because there will be more. "Your matches" and "Design camo" came off: they are the account menu's job, and a profile is not the place to be sold the site's other screens. +- [x] **Matches are public, at an address of their own.** A match is a thing + two accounts did together and its report at `/reports/` has been + readable by anybody since it existed, so a list of them says nothing the + reports do not. `/matches/` is that list, drawn from + `GET /api/profile//matches`, with the same shell rule the profile + uses. What stays the player's own is the way *in*: the API sends a link + into a running match only to the account whose match it is, and a + stranger's row carries a report or nothing. +- [x] **The account menu's Your matches leads somewhere again.** It pointed + at `#operations`, and that address died the day Operations left the + masthead: the router's hash routes are derived from the destinations, so + removing the tab silently removed the route and the menu item landed on + Home. The Operations screen is gone with it, replaced by the player's own + address, and `matches-page.test.mjs` holds the difference — an address + cannot be lost the way a derived route can. +- [x] **The menu stopped naming the camo tab twice.** Camo is a destination in + the masthead, and a second way in from a three-item menu was not a second + thing. +- [x] **A player's page is at `/profile`, not `/players`.** The prefix, the + shell, the module and the api host's unfurl route all say what the page + is. infra's viewer function carries the matching rename, and a prefix + table rather than a branch written for one of them. - [x] **The page and the card are two things that look like one.** A reader arriving from a post sees both within a second of each other, so they share a vocabulary - a tracked label in the accent colour with a rule diff --git a/services/api/src/routes.rs b/services/api/src/routes.rs index ebfd504..d53e7c2 100644 --- a/services/api/src/routes.rs +++ b/services/api/src/routes.rs @@ -102,6 +102,10 @@ pub fn app(state: AppState) -> Router { .route("/api/opponents", get(list_opponents)) .route("/api/opponents/suggested", get(list_suggested_opponents)) .route("/api/matches", post(create_match).get(list_matches)) + // Any player's matches, by handle or DID and without a session. + // Under the profile prefix because that is whose record it is — + // /api/matches is the caller's own, and this one names somebody. + .route("/api/profile/{actor}/matches", get(player_matches)) .route("/api/matches/lobby", post(open_lobby)) .route("/api/matches/{id}/live", get(lobby_live)) .route("/api/matches/{id}/deploy", post(deploy_lobby)) @@ -1877,29 +1881,37 @@ async fn may_touch_match(state: &AppState, row: &crate::db::MatchRow, did: &str) .unwrap_or(false) } -/// The signed-in player's matches, newest first — launched or invited into. +/// One player's matches, newest first — launched or invited into. /// -/// Stored status, except for the rows that say `ready`. Those are the only ones -/// this route hands out a link for, and the only ones whose being wrong costs -/// the player anything: a match that died without reporting it stayed `ready` -/// in the row for good, so the screen kept offering an Open link into a task -/// that no longer exists and the proxy answered it with a 502. +/// `viewer` is who is asking, when anybody is signed in. It decides two +/// things and nothing else: whether a `ready` row is polled, and whether it +/// carries a link into the running match. A player's record is public; the +/// door into their container is not, and a stranger driving one DescribeTasks +/// per listed row is a cost anybody could run up. +/// +/// Stored status, except for the subject's own `ready` rows. Those are the +/// only ones this hands out a link for, and the only ones whose being wrong +/// costs the player anything: a match that died without reporting it stayed +/// `ready` in the row for good, so the screen kept offering an Open link into +/// a task that no longer exists and the proxy answered it with a 502. /// /// `starting` and `running` are left alone on purpose. They are corrected by /// the waiting screen, which is where the Resume button on those rows goes, and /// `advance` reaches for the container's own web server with a three-second /// timeout — a wait this route must not take once per listed row. -async fn list_matches(State(state): State, jar: CookieJar) -> Response { - let Some(did) = session_did(&state, &jar) else { - return StatusCode::UNAUTHORIZED.into_response(); - }; - let rows = match state.db.list_matches(&did, 20).await { +async fn match_summaries( + state: &AppState, + subject: &str, + viewer: Option<&str>, +) -> Result, ()> { + let rows = match state.db.list_matches(subject, 20).await { Ok(rows) => rows, Err(_) => { - tracing::error!(did, "matches: list lookup failed"); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + tracing::error!(did = subject, "matches: list lookup failed"); + return Err(()); } }; + let mine = viewer == Some(subject); let mut matches = Vec::with_capacity(rows.len()); for row in rows { // One query per listed row, bounded by the list's LIMIT. The seats @@ -1913,19 +1925,20 @@ async fn list_matches(State(state): State, jar: CookieJar) -> Response // None in development, which has no cluster to ask; the stored status // is all there is and all there ever was. let status = match state.matches.as_ref() { - Some(matches) if row.status == "ready" => matches.poll(&row).await.status, + Some(matches) if mine && row.status == "ready" => matches.poll(&row).await.status, _ => row.status.clone(), }; // The caller's own client, at their DID. A viewer with no fighting // seat gets no link: there is no client in the container for them. - let url = if status == "ready" + let url = if mine + && status == "ready" && state .db - .is_human_player(&row.id, &did) + .is_human_player(&row.id, subject) .await .unwrap_or(false) { - Some(format!("/match/{}/{}/", row.id, did)) + Some(format!("/match/{}/{}/", row.id, subject)) } else { None }; @@ -1938,7 +1951,48 @@ async fn list_matches(State(state): State, jar: CookieJar) -> Response "players": players, })); } - Json(serde_json::json!({ "matches": matches })).into_response() + Ok(matches) +} + +/// The signed-in player's own matches. Their own record, read as themselves. +async fn list_matches(State(state): State, jar: CookieJar) -> Response { + let Some(did) = session_did(&state, &jar) else { + return StatusCode::UNAUTHORIZED.into_response(); + }; + match match_summaries(&state, &did, Some(&did)).await { + Ok(matches) => Json(serde_json::json!({ "matches": matches })).into_response(), + Err(()) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + +/// Any player's matches, by handle or DID, for whoever is asking. +/// +/// No session required. A match is a thing two accounts did together and it +/// is published as one — the report at `/reports/` has been public since +/// it existed, and a list of them says nothing the reports do not. What is +/// still session-scoped is the way *in*: see `match_summaries`. +/// +/// Resolved the same way the unfurl is (`Atproto::resolve_actor`), so +/// `/matches/@alice.example` and `/matches/did:plc:...` are one address, and +/// an actor nobody can resolve is a 404 rather than an empty list. +async fn player_matches( + State(state): State, + axum::extract::Path(actor): axum::extract::Path, + jar: CookieJar, +) -> Response { + let Some(subject) = state.atproto.resolve_actor(&actor).await else { + return StatusCode::NOT_FOUND.into_response(); + }; + let viewer = session_did(&state, &jar); + match match_summaries(&state, &subject.did, viewer.as_deref()).await { + Ok(matches) => Json(serde_json::json!({ + "did": subject.did, + "handle": subject.handle, + "matches": matches, + })) + .into_response(), + Err(()) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } } async fn match_status( @@ -2973,6 +3027,77 @@ mod tests { assert_eq!(listed["url"], "/match/m1/did:plc:abc/"); } + /// A player's record is public, and the door into their match is not. + /// + /// The list itself is one query and covered by `list_matches`' own tests; + /// what is new here is the split, which is invisible in both directions. + /// A stranger handed a `url` would be handed a way into somebody else's + /// container, and a player denied their own would find their Open link + /// gone from the page the account menu leads to. + /// + /// `match_summaries` directly rather than through the route: the route + /// resolves the address first, and resolution reads a DID document off + /// the network, which `cargo test` must not. + #[tokio::test] + async fn a_strangers_row_carries_no_way_into_the_match() { + let (_dir, state) = loopback_state().await; + seed_match(&state, "did:plc:abc").await; + state.db.set_match_status("m1", "ready").await.unwrap(); + state + .db + .insert_match_players( + "m1", + &[crate::db::MatchPlayer { + slot: "Davion".into(), + control: "human".into(), + did: Some("did:plc:abc".into()), + handle: None, + team: None, + }], + ) + .await + .unwrap(); + + let mine = match_summaries(&state, "did:plc:abc", Some("did:plc:abc")) + .await + .unwrap(); + assert_eq!(mine[0]["url"], "/match/m1/did:plc:abc/"); + + for viewer in [Some("did:plc:stranger"), None] { + let theirs = match_summaries(&state, "did:plc:abc", viewer) + .await + .unwrap(); + assert_eq!(theirs.len(), 1, "the record itself is public"); + assert_eq!(theirs[0]["matchId"], "m1"); + assert!( + theirs[0]["url"].is_null(), + "a viewer who is not the player was handed a way in", + ); + } + } + + /// An address nobody resolves is a 404, not an empty record: a page that + /// answered with no rows would tell a reader this account has never + /// fought, when what happened is that there is no account. + #[tokio::test] + async fn matches_for_nobody_are_not_an_empty_list() { + let (_dir, mut state) = loopback_state().await; + // Resolves nothing, so the handle below is nobody. Offline: the + // fake resolver answers before anything reaches for the network. + state.atproto = Arc::new(Atproto::for_test(state.db.clone(), &[])); + + let response = app(state.clone()) + .oneshot( + Request::get("/api/profile/nobody.example/matches") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + fn with_cookie( request: axum::http::request::Builder, state: &AppState, diff --git a/web/astro.config.mjs b/web/astro.config.mjs index 8bf14a1..0204d15 100644 --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -64,27 +64,28 @@ export default defineConfig({ sourcemap: true, }, - plugins: [profileRewrite()], + plugins: [shellRewrite()], }, }); /** - * What CloudFront does for /profile/, for the dev server. + * What CloudFront does for /profile/<...> and /matches/<...>, for the dev + * server. * * There is one built file behind every player's address - see - * src/pages/profile/index.astro - and in production the distribution's - * viewer-request function rewrites the address onto it. Nothing does that - * here, so a player's page is a 404 in development and only in development, - * which is the worst place for the difference to be. + * src/pages/profile/index.astro and src/pages/matches/index.astro - and in + * production the distribution's viewer-request function rewrites the address + * onto it. Nothing does that here, so those pages are a 404 in development + * and only in development, which is the worst place for the difference to be. * * The rewrite is the same rule as infra's: the prefix decides, not the shape * of what follows it. A handle is full of dots and a DID is full of colons, * and neither is a file. */ /** @returns {import("vite").Plugin} */ -function profileRewrite() { +function shellRewrite() { return { - name: "lance-blue:profile-rewrite", + name: "lance-blue:shell-rewrite", enforce: "pre", /** @param {import("vite").ViteDevServer} server */ configureServer(server) { @@ -96,9 +97,11 @@ function profileRewrite() { */ (req, _res, next) => { const url = req.url ?? ""; - // "/profile", with no slash: trailingSlash is "never", so that is - // the address Astro built the page at. - if (url.startsWith("/profile/")) req.url = "/profile"; + // No trailing slash on the shell's own address: trailingSlash is + // "never", so that is what Astro built each page at. + for (const prefix of ["/profile", "/matches"]) { + if (url.startsWith(`${prefix}/`)) req.url = prefix; + } next(); }, ); diff --git a/web/scripts/match-report-link.test.mjs b/web/scripts/match-report-link.test.mjs index 689e1b4..1827d19 100644 --- a/web/scripts/match-report-link.test.mjs +++ b/web/scripts/match-report-link.test.mjs @@ -20,7 +20,7 @@ const account = await readFile(`${web}src/account.ts`, "utf8"); test("a match that is over offers its report", () => { assert.match( list, - /match\.status === "over"[\s\S]{0,300}reportUrl\(session, match\.matchId\)/, + /match\.status === "over"[\s\S]{0,300}reportUrl\(view, match\.matchId\)/, "a finished match no longer links to its report", ); }); @@ -29,8 +29,15 @@ test("the report is read from this site, with the reader in it", () => { // /reports/* is a behaviour on the site's own distribution, which is what // makes a shared match a lance.blue link rather than an api. one; the // perspective is what makes the page say who won in the second person. + // + // The subject's handle, not the reader's: a row on somebody's /matches page + // is a line in their record, and reporting it from a visitor's point of view + // would name a match that visitor was never in. assert.match(list, /`\/reports\/\$\{matchId\}/); - assert.match(list, /perspective=\$\{encodeURIComponent\(session\.handle\)\}/); + assert.match( + list, + /perspective=\$\{encodeURIComponent\(view\.subject\.handle\)\}/, + ); }); test("a failed match offers nothing to read", () => { @@ -44,10 +51,20 @@ test("a failed match offers nothing to read", () => { ); }); -test("Your matches goes to the list of matches played", () => { +test("Your matches goes to the player's own record, at an address", () => { + // A path and not a hash. The hash it used to be stopped resolving the day + // Operations left the masthead, because the router's set of hash routes is + // derived from the destinations; an address cannot go that way. assert.match( account, - /link\("\/#operations", "Your matches"\)/, + /link\(matchesHref\(session\), "Your matches"\)/, "the account menu points somewhere else again", ); }); + +test("the account menu does not name the camo tab a second time", () => { + assert.ok( + !/"Your camo"/.test(account), + "Camo is a destination in the masthead; the menu had it twice", + ); +}); diff --git a/web/scripts/matches-page.test.mjs b/web/scripts/matches-page.test.mjs new file mode 100644 index 0000000..bb5e27d --- /dev/null +++ b/web/scripts/matches-page.test.mjs @@ -0,0 +1,111 @@ +/** + * A player's matches are an address before they are a page. + * + * The same three things have to agree about `/matches/` as + * about the profile's own prefix, and none of them can see the others: + * destinations.ts parses the address, Astro builds one file for every address + * under it, and a rewrite in front of the bucket puts the two together. + * + * There is a fourth thing here that the profile does not have. This address + * replaced a hash route, `#operations`, and that route died silently: the + * router's set of hash routes is derived from the masthead's destinations, so + * taking Operations off the bar took its address with it and the account + * menu's Your matches led to the front page for anybody who pressed it. The + * checks below are what say the replacement is not the same kind of thing. + * + * Run with `npm test`. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { + actorFromMatchesPath, + matchesHref, + routeForHash, +} from "../src/destinations.ts"; + +const src = fileURLToPath(new URL("../src/", import.meta.url)); + +test("the prefix has a page behind it", () => { + assert.ok( + existsSync(`${src}pages/matches/index.astro`), + "nothing builds /matches, so every player's record is a 404", + ); +}); + +test("an address names a handle or a DID, and both survive the trip", () => { + assert.equal( + actorFromMatchesPath("/matches/alice.example.com"), + "alice.example.com", + ); + assert.equal( + actorFromMatchesPath("/matches/did:plc:abc123"), + "did:plc:abc123", + ); + assert.equal( + actorFromMatchesPath("/matches/did%3Aplc%3Aabc123"), + "did:plc:abc123", + ); + assert.equal( + actorFromMatchesPath("/matches/@alice.example"), + "@alice.example", + ); + // A trailing slash is the same page, not a dead end. + assert.equal( + actorFromMatchesPath("/matches/did:plc:abc123/"), + "did:plc:abc123", + ); + // Nobody named. + assert.equal(actorFromMatchesPath("/matches/"), null); + assert.equal(actorFromMatchesPath("/matches"), null); + // The profile's prefix is not this one, and neither is anything else. + assert.equal(actorFromMatchesPath("/profile/@alice.example"), null); + // A malformed escape is not an address, and must not throw on the way out. + assert.equal(actorFromMatchesPath("/matches/%E0%A4%A"), null); +}); + +test("the address the site writes is the one it can read back", () => { + const did = "did:plc:abc123"; + assert.equal( + matchesHref({ did, handle: "alice.example" }), + "/matches/@alice.example", + ); + assert.equal(matchesHref({ did, handle: null }), `/matches/${did}`); + assert.equal( + actorFromMatchesPath(matchesHref({ did, handle: "alice.example" })), + "@alice.example", + ); + assert.equal(actorFromMatchesPath(matchesHref({ did, handle: null })), did); +}); + +// What went wrong last time, held as a check rather than as a comment. +test("#operations is not a route, and nothing links to one", async () => { + assert.equal(routeForHash("#operations"), null); + const account = await readFile(`${src}account.ts`, "utf8"); + const pilot = await readFile(`${src}screens/pilot.ts`, "utf8"); + for (const [name, source] of [ + ["account.ts", account], + ["screens/pilot.ts", pilot], + ]) { + assert.ok( + !/#operations/.test(source), + `${name} links to a hash route that no longer resolves`, + ); + } +}); + +test("a row about somebody else offers no way into their match", async () => { + const list = await readFile(`${src}screens/match-list.ts`, "utf8"); + // The Resume button hands the waiting screen a session, and waiting for a + // match is something only the player in it can do. The link into a running + // match is the API's to withhold - it sends no url to anybody else - but + // this one is the browser's. + assert.match( + list, + /session &&\s*isOwn\(view\) &&\s*\(match\.status === "starting"/, + "Resume is offered on a stranger's row", + ); +}); diff --git a/web/scripts/matches-screen.test.mjs b/web/scripts/matches-screen.test.mjs index b06d21a..d4f08b6 100644 --- a/web/scripts/matches-screen.test.mjs +++ b/web/scripts/matches-screen.test.mjs @@ -28,7 +28,7 @@ import { fileURLToPath } from "node:url"; const src = fileURLToPath(new URL("../src/", import.meta.url)); const matches = await readFile(`${src}screens/matches.ts`, "utf8"); const list = await readFile(`${src}screens/match-list.ts`, "utf8"); -const operations = await readFile(`${src}screens/operations.ts`, "utf8"); +const record = await readFile(`${src}matches-page.ts`, "utf8"); const styles = await readFile(`${src}styles.css`, "utf8"); test("the live matches, then the day's fight, then the Modes", () => { @@ -168,7 +168,7 @@ test("only the kept-nothing case is the caller's; a failed read still says so", ); // The list's own catch, not scenarioNames' — that one is above it in the // file and swallows its failure by design. - const failed = list.match(/Promise\.all\(\[listMatches[\s\S]*?\n {4}\}\);/); + const failed = list.match(/Promise\.all\(\[read\(\)[\s\S]*?\n {4}\}\);/); assert.ok(failed, "the read of the list moved or was restructured"); assert.match( failed[0], @@ -178,11 +178,20 @@ test("only the kept-nothing case is the caller's; a failed read still says so", ); }); -test("Operations still says it in a line, since it has no other section", () => { - assert.match( - operations, - /matchList\(session, isFinished, "No finished matches yet\."\)/, - "Operations' empty case changed - the screen is that list, so removing " + - "it would leave a heading over nothing", +test("a player's own record says it in a line, since it has no other section", () => { + // The page is that list, so removing it would leave a heading over nothing — + // and the line differs by whose record it is, because "play one and it lands + // here" is not something to tell a visitor about somebody else. + assert.match(record, /own\s*\?\s*"No matches yet\./); + assert.match(record, /:\s*"This player has not finished a match yet\."/); +}); + +test("a player's record is read from the public route, not the session's", () => { + // /api/matches is the caller's own list. A page about somebody else that + // read it would show the reader their own matches under a stranger's name. + assert.ok( + !/listMatches\b/.test(record), + "the matches page reads the session-scoped list", ); + assert.match(record, /listPlayerMatches\(asked\)/); }); diff --git a/web/scripts/profile-page.test.mjs b/web/scripts/profile-page.test.mjs index 4e6e4b0..59f627a 100644 --- a/web/scripts/profile-page.test.mjs +++ b/web/scripts/profile-page.test.mjs @@ -82,8 +82,10 @@ test("the dev server serves the shell for an address under the prefix", async () fileURLToPath(new URL("../astro.config.mjs", import.meta.url)), "utf8", ); - assert.match(config, /startsWith\("\/profile\/"\)/); - assert.match(config, /req\.url = "\/profile"/); + // Both shells, from one table: the profile's and the record's. + assert.match(config, /for \(const prefix of \["\/profile", "\/matches"\]\)/); + assert.match(config, /url\.startsWith\(`\$\{prefix\}\/`\)/); + assert.match(config, /req\.url = prefix/); }); test("the page reads the address rather than being built per player", async () => { diff --git a/web/src/account.ts b/web/src/account.ts index 2c3d7b4..e34f53d 100644 --- a/web/src/account.ts +++ b/web/src/account.ts @@ -32,6 +32,7 @@ import { PLAY_LABEL, RESUME_LABEL, isAppPage, + matchesHref, profileHref, } from "./destinations"; import { el } from "./dom"; @@ -220,8 +221,10 @@ function signedIn(session: Session): HTMLElement { // Your own player page: the same address anybody else's is at, so what // you see of yourself is what you can hand somebody else. link(profileHref(session), "Your account"), - link("/#operations", "Your matches"), - link("/#camo", "Your camo"), + // Your own record, at the same address anybody else's is at. Camo is not + // here: it is a destination in the masthead, and a second way in from a + // menu three items long was naming the same tab twice. + link(matchesHref(session), "Your matches"), report, signOutButton(status), status, diff --git a/web/src/api.ts b/web/src/api.ts index ac3062d..2e773d1 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -662,6 +662,35 @@ export async function listMatches(): Promise { return body.matches; } +/** Whose matches these are, resolved by the API from the address. */ +export interface PlayerMatches { + did: string; + handle: string | null; + matches: MatchSummary[]; +} + +/** + * Any player's matches, newest first, by handle or DID. + * + * No session needed — a match is public, and so is the report at the end of + * one. What a session still changes is the way *in*: a row carries a link + * into a running match only when the caller is the player whose row it is. + * + * `null` for an actor nobody resolves, which is the page's not-found rather + * than an error: the address was typed or pasted, and the account is what is + * missing. + */ +export async function listPlayerMatches( + actor: string, +): Promise { + const response = await call( + `/api/profile/${encodeURIComponent(actor)}/matches`, + ); + if (response.status === 404) return null; + if (!response.ok) throw new ApiError("The matches could not be loaded."); + return (await response.json()) as PlayerMatches; +} + export async function matchStatus(id: string): Promise { const response = await call(`/api/matches/${encodeURIComponent(id)}`); if (!response.ok) throw new ApiError("The match status could not be read."); diff --git a/web/src/chrome.ts b/web/src/chrome.ts index 3142314..26629b4 100644 --- a/web/src/chrome.ts +++ b/web/src/chrome.ts @@ -15,7 +15,6 @@ import { PROJECT_LINKS } from "./project-links"; export type Route = | "home" | "matches" - | "operations" | "camo" | "hangar" | "about" diff --git a/web/src/destinations.ts b/web/src/destinations.ts index 94de66c..9f398fb 100644 --- a/web/src/destinations.ts +++ b/web/src/destinations.ts @@ -212,10 +212,6 @@ export function destinationForRoute(route: string): string { export function isAllowed(route: string, signedIn: boolean): boolean { if (signedIn) return true; const id = destinationForRoute(route); - // Operations is reached from the account menu rather than from the bar, so - // it is not a destination any more - but it is still a player's own record, - // and an address nobody may type their way into without a session. - if (id === "operations") return false; return !DESTINATIONS.find((d) => d.id === id)?.signedIn; } @@ -320,6 +316,17 @@ export function lobbyIdFromHash(hash: string): string | null { */ const PROFILE_PREFIX = "/profile/"; +/** + * A player's matches, the site's third parameterized address. + * + * The same rule as the profile's, one prefix along: a match is a thing two + * accounts did together and the record of it is public, so what somebody has + * played is an address anybody can be handed. `/matches/@alice.example` is + * hers whoever is reading, and your own is the one the account menu leads to + * rather than a screen that only exists while you are signed in. + */ +const MATCHES_PREFIX = "/matches/"; + /** * The page's address for an account. * @@ -330,11 +337,23 @@ export function profileHref(actor: { did: string; handle: string | null; }): string { - return `${PROFILE_PREFIX}${actor.handle ? `@${actor.handle}` : actor.did}`; + return `${PROFILE_PREFIX}${segmentFor(actor)}`; +} + +/** Their matches, at the address written the same way. */ +export function matchesHref(actor: { + did: string; + handle: string | null; +}): string { + return `${MATCHES_PREFIX}${segmentFor(actor)}`; +} + +function segmentFor(actor: { did: string; handle: string | null }): string { + return actor.handle ? `@${actor.handle}` : actor.did; } /** - * The handle or DID an address names, or null where it names nobody. + * The handle or DID a profile address names, or null where it names nobody. * * A leading @ is part of the handle's address and is left on: identity.ts * takes it off before resolving, the way it takes one off anything typed. @@ -343,8 +362,17 @@ export function profileHref(actor: { * worth a dead end. */ export function actorFromPath(pathname: string): string | null { - if (!pathname.startsWith(PROFILE_PREFIX)) return null; - const segment = pathname.slice(PROFILE_PREFIX.length).split("/")[0] ?? ""; + return actorUnder(PROFILE_PREFIX, pathname); +} + +/** The same, for a matches address. */ +export function actorFromMatchesPath(pathname: string): string | null { + return actorUnder(MATCHES_PREFIX, pathname); +} + +function actorUnder(prefix: string, pathname: string): string | null { + if (!pathname.startsWith(prefix)) return null; + const segment = pathname.slice(prefix.length).split("/")[0] ?? ""; if (segment === "") return null; try { // Nothing here writes an encoded address any more, and one can still diff --git a/web/src/matches-page.ts b/web/src/matches-page.ts new file mode 100644 index 0000000..a1c8f69 --- /dev/null +++ b/web/src/matches-page.ts @@ -0,0 +1,225 @@ +/** + * A player's matches: /matches/ and /matches/, the same page. + * + * The record of what somebody has played, at an address anybody can be handed + * — the same shape as the profile at /profile/<...>, one prefix along, and + * built from the same shell for the same reason: there is no list of players + * for a build to prerender a page each from, so the distribution rewrites + * every address under the prefix onto one file and this reads which player + * off the address. + * + * It is public. A match is a thing two accounts did together, its report at + * /reports/ has been readable by anybody since it existed, and a list of + * them says nothing the reports do not. What is still the player's own is the + * way *in*: the API sends a link into a running match only to the account + * whose match it is, so a stranger's rows carry a report or nothing. + * + * Signed in at your own address, this is the whole of what the account menu's + * Your matches used to open — which was a hash route that stopped resolving + * when Operations left the masthead, and is an address now precisely so that + * it cannot happen twice. + */ + +import { + currentSession, + listPlayerMatches, + type MatchSummary, + type PlayerMatches, + type Session, +} from "./api"; +import { footer, pageHead } from "./chrome"; +import { actorFromMatchesPath, matchesHref, profileHref } from "./destinations"; +import { el, render } from "./dom"; +import { playerFace } from "./player-face"; +import { matchList, type MatchView } from "./screens/match-list"; + +/** + * What the page says while it is finding out who it is about. + * + * A live region, because the shell shipped `aria-busy` and this is what takes + * its place. The same line the profile page uses, for the same wait: this + * page asks the API, which resolves the address before it can answer. + */ +function looking(): Node[] { + return [ + el("section", { className: "card prose", role: "status" }, [ + el("p", { textContent: "Frobnicating the quockerwodger…" }), + ]), + footer(), + ]; +} + +/** + * Nobody by that name. + * + * A 200 rather than a 404: the address is one the site has, and it is the + * account that does not exist. What was asked for is on the page, since that + * is how somebody spots their own typo, and it goes in through textContent + * because it is whatever was typed. + */ +function nobody(actor: string): Node[] { + return [ + pageHead( + "Snollygoster brabble nudiustertian", + "Absquatulate vellichor gongoozler, mumpsimus cattywampus taradiddle skedaddle.", + ), + el("section", { className: "card" }, [ + el("code", { className: "dead-path", textContent: actor }), + el("a", { + className: "pilot-action", + href: "/#", + textContent: "Go home", + }), + ]), + footer(), + ]; +} + +/** + * The small card over the list: who this record belongs to. + * + * A reader arrives here from a post, from a lineup or from their own menu, + * and a page that opened straight into rows would make them read the address + * bar to find out whose rows they are. The picture and the handle answer that + * in one line, and the name is the link onward to the page that is about the + * player rather than about their matches. + * + * Deliberately smaller than the profile's own head: this is a label on a + * list, not a second profile. It draws the face through the same function the + * profile does, so an account looks like itself in both places. + */ +function pilotStrip(actor: { + did: string; + handle: string | null; +}): HTMLElement { + return el("section", { className: "pilot-strip" }, [ + el("a", { className: "pilot-strip-face", href: profileHref(actor) }, [ + playerFace(actor), + ]), + el("div", { className: "pilot-strip-who" }, [ + el("a", { + className: "pilot-strip-name", + href: profileHref(actor), + textContent: actor.handle ? `@${actor.handle}` : actor.did, + }), + // The DID under the handle, because the handle is rented and this is + // not. The same pairing the pilot card and the hover card make. + el("p", { className: "pilot-strip-did", textContent: actor.did }), + ]), + ]); +} + +/** + * The page, once the API has said who the address names. + * + * `read` is handed to the list rather than an array, so the one case where + * the API answered the identity and not the rows still says so in place: a + * rejected read is the list's own error line, and an empty array would tell a + * reader this player has never fought. + */ +function record( + actor: { did: string; handle: string | null }, + read: () => Promise, + session: Session | null, +): Node[] { + const view: MatchView = { subject: actor, session }; + const own = session?.did === actor.did; + + return [ + pilotStrip(actor), + el("section", { className: "card matches" }, [ + el("h2", { textContent: own ? "Your matches" : "Matches played" }), + // Already read, so the list is handed what it has rather than asking + // again: the identity and the rows came back in one answer, and a + // second request for the same array would be a second resolution of the + // same handle. + matchList( + view, + read, + () => true, + own + ? "No matches yet. Play one and it lands here." + : "This player has not finished a match yet.", + ), + ]), + footer(), + ]; +} + +/** The answer, rather than the error kept beside it. */ +function isRecord(answer: unknown): answer is PlayerMatches { + return typeof answer === "object" && answer !== null && "matches" in answer; +} + +async function start(): Promise { + const app = document.querySelector("#app"); + const asked = actorFromMatchesPath(window.location.pathname); + + if (!asked) { + // /matches with nobody after it. Your own record is the only sensible + // answer, and a stranger is told what the address is for. + const session = await currentSession().catch(() => null); + if (session) { + window.location.replace(matchesHref(session)); + return; + } + render(...nobody(window.location.pathname)); + app?.removeAttribute("aria-busy"); + return; + } + + render(...looking()); + + // Both at once: the session is ours and the record names an account on the + // network, and neither answer is worth waiting on the other for. + const [answer, session] = await Promise.all([ + listPlayerMatches(asked).catch((error: unknown) => { + // Kept rather than thrown: the address still names somebody as far as + // anybody here knows, so this is a page with a list that could not be + // read - not a not-found, which would tell a reader the account is gone + // when what is down is ours. + console.warn("matches: the record could not be read", error); + return error; + }), + currentSession().catch(() => null), + ]); + + if (answer === null) { + render(...nobody(asked)); + app?.removeAttribute("aria-busy"); + return; + } + + const held = isRecord(answer) ? answer : null; + const actor = held + ? { did: held.did, handle: held.handle } + : // Nothing answered, so the address is the only name there is. A DID + // form is already the account's own name; a handle is shown as typed, + // which is what the reader asked about. + { did: asked, handle: null }; + const read = () => + held ? Promise.resolve(held.matches) : Promise.reject(answer); + + markCanonical(actor); + document.title = `${actor.handle ? `@${actor.handle}` : actor.did} · Matches · lance.blue`; + render(...record(actor, read, session)); + app?.removeAttribute("aria-busy"); +} + +/** + * The one address this page is published at, for anything that has to name it + * once: the handle's, or the DID's for an account with no handle to use. Two + * addresses answering one page is a feature for whoever is typing and a + * problem for anything counting them. + */ +function markCanonical(actor: { did: string; handle: string | null }): void { + const href = new URL(matchesHref(actor), window.location.origin).href; + const existing = document.querySelector( + "link[rel=canonical]", + ); + const link = existing ?? el("link", { rel: "canonical" }); + link.href = href; + if (!existing) document.head.append(link); +} + +void start(); diff --git a/web/src/pages/matches/index.astro b/web/src/pages/matches/index.astro new file mode 100644 index 0000000..b80c0d7 --- /dev/null +++ b/web/src/pages/matches/index.astro @@ -0,0 +1,32 @@ +--- +/** + * The shell every player's matches are served from. + * + * One file, for /matches/ and /matches/ alike: there is no list + * of players for a build to prerender a page each from, so the distribution + * rewrites every address under the prefix to this file, and matches-page.ts + * reads which player off the address. See infra's index-rewrite.js. + * + * No unfurl route behind this one, unlike the profile's shell: nothing on the + * api host renders a card for a player's record yet, so a card fetcher gets + * this page and the site's own tags. + * + * No current= on the layout: a player's matches are not one of the masthead's + * destinations, so none of them is marked. + */ +import Base from "../../layouts/Base.astro"; +--- + + +
+ +
+ + diff --git a/web/src/player-face.ts b/web/src/player-face.ts new file mode 100644 index 0000000..ae602cb --- /dev/null +++ b/web/src/player-face.ts @@ -0,0 +1,54 @@ +/** + * A player's picture, or the letter that stands in for it. + * + * Two pages draw the same account: the profile at /profile/<...> and the + * record at /matches/<...>. The drawing is one function so that a face is one + * thing wherever it appears — the same fallback, the same read of the same + * blob, and the same classes for the same shape. + * + * The picture is the network's, not ours: avatars.ts reads it out of the + * player's own repository, and an account with none keeps the monogram + * rather than growing a hole where a picture would have gone. + */ + +import { el } from "./dom"; +import { avatarUrl, monogram } from "./profile"; + +/** Who is being drawn. The same pair every address here resolves to. */ +export interface Face { + did: string; + handle: string | null; +} + +/** + * The box, monogram already in it, and the portrait swapped in if one loads. + * + * Swapped on `load` rather than on the URL arriving: a broken blob would + * otherwise replace a readable letter with a broken-image icon. + */ +export function playerFace(actor: Face): HTMLElement { + const box = el("span", { className: "profile-face" }, [ + el("span", { + className: "profile-monogram", + textContent: monogram(actor), + }), + ]); + + void avatarUrl(actor.did) + .then((src) => { + if (!src) return; + const image = el("img", { + className: "profile-portrait", + src, + // Decorative: the handle is beside it. + alt: "", + decoding: "async", + }); + image.addEventListener("load", () => box.replaceChildren(image)); + }) + .catch((error: unknown) => { + console.warn("profile: the picture could not be found", error); + }); + + return box; +} diff --git a/web/src/profile-page.ts b/web/src/profile-page.ts index 258ead8..c85fe7c 100644 --- a/web/src/profile-page.ts +++ b/web/src/profile-page.ts @@ -20,14 +20,14 @@ * match count, and it is only ever your own. */ -import { currentSession, listMatches, type Session } from "./api"; +import { currentSession, listPlayerMatches, type Session } from "./api"; import { stashChallenge } from "./challenge-stash"; import { footer, pageHead } from "./chrome"; import { el, render } from "./dom"; -import { actorFromPath, profileHref } from "./destinations"; +import { actorFromPath, matchesHref, profileHref } from "./destinations"; import { resolveActor, type Actor } from "./identity"; import { relationship } from "./relationships"; -import { avatarUrl, monogram } from "./profile"; +import { playerFace } from "./player-face"; /** * What the page says while it is finding out who it is about. @@ -93,8 +93,6 @@ type Figure = { readonly read?: (actor: Actor) => Promise; /** Why the figure is missing. Placeholders only. */ readonly pending?: string; - /** Readable about yourself and about nobody else, with the reason. */ - readonly selfOnly?: string; }; const SCORES: readonly Figure[] = [ @@ -105,11 +103,11 @@ const SCORES: readonly Figure[] = [ const COUNTS: readonly Figure[] = [ { label: "Matches", - // Ours rather than the network's, and only about you: the list is - // session-scoped until a finished match is a record in the player's own - // repository. See match-records. - read: async () => (await listMatches()).length, - selfOnly: "Only the player can see their matches for now.", + // Ours rather than the network's, and about whoever the page is about: a + // match is a thing two accounts did together, and the record of one is + // public. The row under this figure is the same list, at /matches. + read: async (actor) => + (await listPlayerMatches(actor.did))?.matches.length ?? 0, }, { label: "Forces", pending: "Nothing saves a force yet." }, { @@ -125,41 +123,13 @@ const COUNTS: readonly Figure[] = [ }, ]; -/** The picture, or the letter it stands in for. */ -function face(actor: Actor): HTMLElement { - const box = el("span", { className: "profile-face" }, [ - el("span", { - className: "profile-monogram", - textContent: monogram(actor), - }), - ]); - - void avatarUrl(actor.did) - .then((src) => { - if (!src) return; - const image = el("img", { - className: "profile-portrait", - src, - // Decorative: the handle is beside it. - alt: "", - decoding: "async", - }); - image.addEventListener("load", () => box.replaceChildren(image)); - }) - .catch((error: unknown) => { - console.warn("profile: the picture could not be found", error); - }); - - return box; -} - /** * One figure, filled in when its own read lands. * * Every one starts as an em dash, so the page is complete and readable from * the first frame and nothing on it moves as the numbers arrive. */ -function figure(spec: Figure, actor: Actor, self: boolean): HTMLElement { +function figure(spec: Figure, actor: Actor): HTMLElement { const value = el("span", { className: "figure-value", textContent: "—" }); const label = el("span", { className: "figure-label", @@ -167,14 +137,13 @@ function figure(spec: Figure, actor: Actor, self: boolean): HTMLElement { }); const row = el("div", { className: "figure" }, [label, value]); - const withheld = spec.selfOnly && !self ? spec.selfOnly : null; - if (!spec.read || withheld) { + if (!spec.read) { row.classList.add("figure-empty"); - row.title = withheld ?? spec.pending ?? "Not recorded yet."; + row.title = spec.pending ?? "Not recorded yet."; label.append( el("span", { className: "visually-hidden", - textContent: withheld ? " (not public)" : " (not recorded yet)", + textContent: " (not recorded yet)", }), ); return row; @@ -364,7 +333,6 @@ function blueskyMark(): SVGSVGElement { /** The page, once the account behind the address is known. */ function profile(actor: Actor, session: Session | null): Node[] { - const self = session?.did === actor.did; // The row reads label, rule, then what there is to do about this player: // the action first and where else they are after it, since one of those is // a decision and the other is a footnote. Friending will land here too, @@ -375,6 +343,14 @@ function profile(actor: Actor, session: Session | null): Node[] { const trouble = el("p", { className: "error", role: "alert" }); const actions = [ challengeButton(actor, session, trouble), + // Their record, which is a page rather than a press: an address anybody + // can be handed, and the one thing here that is about what this player + // has actually done. + el("a", { + className: "profile-record plate", + href: matchesHref(actor), + textContent: "Matches", + }), accountMark(actor, session), ] .filter((node) => node !== null) @@ -394,17 +370,17 @@ function profile(actor: Actor, session: Session | null): Node[] { textContent: actor.handle ? `@${actor.handle}` : actor.did, }), ]), - el("div", { className: "profile-plate" }, [face(actor)]), + el("div", { className: "profile-plate" }, [playerFace(actor)]), ]), el( "div", { className: "profile-scores" }, - SCORES.map((spec) => figure(spec, actor, self)), + SCORES.map((spec) => figure(spec, actor)), ), el( "div", { className: "profile-counts" }, - COUNTS.map((spec) => figure(spec, actor, self)), + COUNTS.map((spec) => figure(spec, actor)), ), ]), footer(), diff --git a/web/src/router.ts b/web/src/router.ts index 12f6830..dc3f49b 100644 --- a/web/src/router.ts +++ b/web/src/router.ts @@ -50,7 +50,6 @@ import { lobbyInviteScreen } from "./screens/lobby-invite"; import { matchesScreen } from "./screens/matches"; import { notFoundScreen } from "./screens/not-found"; import { hangarScreen } from "./screens/hangar"; -import { operationsScreen } from "./screens/operations"; /** * Which screen this address means. Everything about the address itself is @@ -302,10 +301,6 @@ export async function start(): Promise { render(...matchesScreen(session)); return; } - if (route === "operations") { - render(...operationsScreen(session)); - return; - } } if (route === "lobby") { @@ -376,11 +371,11 @@ export async function start(): Promise { void start(); }; - // Play and Operations are nothing but what the API answers — the list, the - // scenarios, the opponents — so there is no page underneath to keep, and - // the error screen is the page. Camo and About returned above without ever - // reading a session, so an unread one costs them nothing. - if (route === "matches" || route === "operations") { + // Play is nothing but what the API answers — the list, the scenarios, the + // opponents — so there is no page underneath to keep, and the error screen + // is the page. Camo and About returned above without ever reading a + // session, so an unread one costs them nothing. + if (route === "matches") { render(...errorScreen(message, retry)); return; } diff --git a/web/src/screens/match-list.ts b/web/src/screens/match-list.ts index 3f49128..a415ea0 100644 --- a/web/src/screens/match-list.ts +++ b/web/src/screens/match-list.ts @@ -3,15 +3,21 @@ * * Two screens show matches and they show a different half of the same list: * Play has the ones still going, because that screen is for getting into a - * game, and Operations has the ones that are over, because that screen is the - * record of what was played. The row is identical either way — what a match - * was, who it was against, when, and the way in if there is still one — so it - * lives here rather than in whichever screen had it first. + * game, and a player's own address at /matches/ has all of + * them, because that page is the record of what was played. The row is + * identical either way — what a match was, who it was against, when, and the + * way in if there is still one — so it lives here rather than in whichever + * screen had it first. + * + * A row is written about somebody, and read by somebody else. Those were the + * same person while the only list was your own, and they are not any more: + * `MatchView` is the pair, and everything on a row that used to say "you" + * says the subject instead. What the *reader* decides is only the way in — a + * link into a running match, or a Resume that needs a session to wait with. */ import { fetchScenarios, - listMatches, matchUrl, type MatchSummary, type Session, @@ -30,12 +36,31 @@ const LABEL: Record = { }; /** - * Who a match was against, from its stored seats: everyone but the viewer, + * Whose record this is, and who is reading it. + * + * `session` is the reader, and is null for a signed-out visitor at somebody's + * public address. `subject` is who the list is about — the reader themselves + * on their own page, and a stranger on anybody else's. + */ +export interface MatchView { + subject: { did: string; handle: string | null }; + session: Session | null; +} + +/** Their own record, so the row may offer a way back into the match. */ +function isOwn(view: MatchView): boolean { + return view.session?.did === view.subject.did; +} + +/** + * Who a match was against, from its stored seats: everyone but the subject, * named by the handle they fought under. Empty for matches that predate * seats, which the row then simply does not say. */ -function against(session: Session, match: MatchSummary): string { - const others = (match.players ?? []).filter((p) => p.did !== session.did); +function against(view: MatchView, match: MatchSummary): string { + const others = (match.players ?? []).filter( + (p) => p.did !== view.subject.did, + ); if (!others.length) return ""; const names = others.map((p) => p.handle ? `@${p.handle}` : (p.did ?? "").slice(0, 16) || p.slot, @@ -44,24 +69,28 @@ function against(session: Session, match: MatchSummary): string { } /** - * The report for a finished match, phrased for whoever is reading it. + * The report for a finished match, phrased for whose record it is. * * `?perspective=` is what makes the page say "you won" rather than naming two - * strangers; the report renders without it, so a session with no handle just - * gets the neutral wording. Served from this site rather than the API's own - * host: `/reports/*` is a behaviour on the distribution, which is what makes a - * shared match a lance.blue link. + * strangers; the report renders without it, so a subject with no handle just + * gets the neutral wording. The subject rather than the reader, because a row + * on somebody's page is a line in their record — reading it from a visitor's + * point of view would report a match they were never in. + * + * Served from this site rather than the API's own host: `/reports/*` is a + * behaviour on the distribution, which is what makes a shared match a + * lance.blue link. */ -function reportUrl(session: Session, matchId: string): string { - const perspective = session.handle - ? `?perspective=${encodeURIComponent(session.handle)}` +function reportUrl(view: MatchView, matchId: string): string { + const perspective = view.subject.handle + ? `?perspective=${encodeURIComponent(view.subject.handle)}` : ""; return `/reports/${matchId}${perspective}`; } /** One row per match: what it is, when it was, and the way in if there is one. */ function matchRow( - session: Session, + view: MatchView, match: MatchSummary, names: Map, ): HTMLElement { @@ -71,6 +100,7 @@ function matchRow( }); const name = scenarioName(match.scenario, names); + const session = view.session; let action: HTMLElement; if (match.status === "ready" && match.url) { // The private link itself: right-click to copy, click to enter. @@ -78,7 +108,11 @@ function matchRow( href: matchUrl(match.url), textContent: "Open match", }); - } else if (match.status === "starting" || match.status === "running") { + } else if ( + session && + isOwn(view) && + (match.status === "starting" || match.status === "running") + ) { action = el("button", { type: "button", className: "secondary small-btn", @@ -93,7 +127,7 @@ function matchRow( // falls through to the dash below - a match that never got a result has // no report to read. action = el("a", { - href: reportUrl(session, match.matchId), + href: reportUrl(view, match.matchId), textContent: "Report", }); } else { @@ -116,7 +150,7 @@ function matchRow( className: "match-id", textContent: match.matchId.slice(0, 8), }), - el("span", { className: "match-vs", textContent: against(session, match) }), + el("span", { className: "match-vs", textContent: against(view, match) }), el("span", { className: match.status === "failed" ? "error" : "muted", textContent: LABEL[match.status] ?? match.status, @@ -155,6 +189,10 @@ function scenarioNames(): Promise> { /** * The list, filled in when the API answers. * + * `read` is where the rows come from: your own session-scoped list on Play, + * and the public per-player route on a /matches address. The screen chooses, + * because the two answer the same shape and neither belongs to the row. + * * `keep` is which half of the list this screen is: the whole list is read * either way, because the API has no way to ask for one half and the answer is * one small array. @@ -167,7 +205,8 @@ function scenarioNames(): Promise> { * player they have no matches when the truth is that nobody knows. */ export function matchList( - session: Session, + view: MatchView, + read: () => Promise, keep: (match: MatchSummary) => boolean, empty: string | (() => void), ): HTMLElement { @@ -177,7 +216,7 @@ export function matchList( // Both before any of it, so the list arrives complete and no row grows a // name under the reader after it is already on screen. - Promise.all([listMatches(), scenarioNames()]) + Promise.all([read(), scenarioNames()]) .then(([matches, catalog]) => { list.replaceChildren(); const shown = matches.filter(keep); @@ -190,7 +229,7 @@ export function matchList( // Every match, oldest included. The sidebar this came from hid anything // finished more than a day ago because four lines each would have pushed // the live ones off the bottom; a screen has the room. - for (const match of shown) list.append(matchRow(session, match, catalog)); + for (const match of shown) list.append(matchRow(view, match, catalog)); }) .catch((error: unknown) => { console.warn("matches: the list could not be read", error); @@ -198,7 +237,8 @@ export function matchList( el("p", { className: "error", role: "alert", - textContent: "Your matches could not be loaded.", + // Whose list failed, since this one is not always yours. + textContent: "The matches could not be loaded.", }), ); }); diff --git a/web/src/screens/matches.ts b/web/src/screens/matches.ts index 79278cc..8c7bab7 100644 --- a/web/src/screens/matches.ts +++ b/web/src/screens/matches.ts @@ -6,9 +6,9 @@ * has to explain what an account is or what a DID is. It is the one place the * challenge flow starts from and the one place it comes back to. * - * Only the live matches. The ones that are over are Operations', so this - * screen stays what a player opens to get into a game rather than growing a - * ledger of every match they have ever played underneath it. + * Only the live matches. Every match a player has ever fought is at their own + * address, /matches/, so this screen stays what a player opens + * to get into a game rather than growing a ledger underneath it. * * A match already going outranks the day's fight, so the in-progress section * is returned above the daily card — and a player with nothing going sees the @@ -21,7 +21,7 @@ * headings were naming what was already named. */ -import { isFinished, type Session } from "../api"; +import { isFinished, listMatches, type Session } from "../api"; import { footer } from "../chrome"; import { el, render } from "../dom"; import { nav } from "../nav"; @@ -109,7 +109,9 @@ export function matchesScreen(session: Session): Node[] { ]); inProgress.append( matchList( - session, + // Your own list, read as yourself: this screen is behind sign-in. + { subject: session, session }, + listMatches, (match) => !isFinished(match), () => { inProgress.remove(); diff --git a/web/src/screens/operations.ts b/web/src/screens/operations.ts deleted file mode 100644 index 495f5ee..0000000 --- a/web/src/screens/operations.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Operations: the matches a player has finished. - * - * The other half of the list Play shows, and the reason Play only shows the - * live ones. Behind sign-in, like Play — it is the player's own record, and - * the router will not put it up without a session. - */ - -import { isFinished, type Session } from "../api"; -import { footer, pageHead } from "../chrome"; -import { el } from "../dom"; -import { matchList } from "./match-list"; - -export function operationsScreen(session: Session): Node[] { - return [ - pageHead("Operations"), - el("section", { className: "card matches" }, [ - el("h2", { textContent: "Matches played" }), - matchList(session, isFinished, "No finished matches yet."), - ]), - footer(), - ]; -} diff --git a/web/src/screens/pilot.ts b/web/src/screens/pilot.ts index 54cdae1..b15ece4 100644 --- a/web/src/screens/pilot.ts +++ b/web/src/screens/pilot.ts @@ -16,6 +16,7 @@ */ import { listMatches, type Session } from "../api"; +import { matchesHref } from "../destinations"; import { el } from "../dom"; import { notReadyNote, soonStamp } from "../soon-stamp"; import { accountName, avatarUrl, monogram } from "../profile"; @@ -87,7 +88,7 @@ export function pilotCard(session: Session): HTMLElement { identity, statGrid(session), el("div", { className: "pilot-actions" }, [ - el("a", { className: "pilot-action", href: "/#matches" }, [ + el("a", { className: "pilot-action", href: matchesHref(session) }, [ "Your matches", ]), el("a", { className: "pilot-action", href: "/#camo" }, ["Design camo"]), diff --git a/web/src/styles.css b/web/src/styles.css index 8d6d96b..331d854 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2929,6 +2929,26 @@ footer .debug { cursor: default; } +/* Their record, beside the action: an outline where Challenge is filled, + because this is the page next door and not the thing to do here. Text, so + it cannot use .profile-badge - that one is a fixed square cut for a mark. */ +.profile-record { + padding: 0.3rem 0.9rem; + border: 1px solid var(--line); + color: var(--muted); + font-family: var(--font-labels); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + text-decoration: none; + white-space: nowrap; +} + +.profile-record:hover { + color: var(--accent); + border-color: var(--accent); +} + .profile-badge { display: grid; place-items: center; @@ -3075,6 +3095,51 @@ footer .debug { color: var(--muted); } +/* The small card over a player's matches: whose record this is. A label on a + list rather than a second profile, so it borrows the face's drawing and + nothing else of the profile head's size. */ +.pilot-strip { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} + +.pilot-strip-face { + display: block; + line-height: 0; +} + +.pilot-strip .profile-face { + width: 3.5rem; + height: 3.5rem; +} + +.pilot-strip .profile-monogram { + font-size: 1.4rem; +} + +.pilot-strip-who { + display: flex; + flex-direction: column; + gap: 0.15rem; + /* The handle is a domain somebody else chose and can be long; the DID + underneath always is. Neither may push the row wider than the page. */ + min-width: 0; +} + +.pilot-strip-name { + font-family: var(--font-labels); + font-weight: 600; +} + +.pilot-strip-did { + margin: 0; + font-size: 0.8rem; + color: var(--muted); + overflow-wrap: anywhere; +} + /* A standing: the word, a leader, the figure. The leader is what makes an em dash read as a gauge at rest rather than as a number somebody forgot. */ .profile-scores {