Ship Steam login, D1 player sync, and cached「我」dashboard.

Players get a fast TTL-backed homepage (local profile / Cloudflare D1) with dense UI polish; login unlocks /home without blocking on every OpenDota refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-08-01 01:24:30 +08:00
co-authored by Cursor
parent 4a61aeeb26
commit f5b7011c45
65 changed files with 7304 additions and 552 deletions
+910 -101
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
/* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.6.16";
var SITE_VERSION = "0.6.54";
var SITE_ORIGIN = "";
var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = "";
@@ -0,0 +1,209 @@
/**
* Shared Steam OpenID + signed-session helpers for Pages Functions.
*
* Env: STEAM_API_KEY, SESSION_SECRET (required for login/me).
* Cookie: climperor_steam (HttpOnly, signed payload).
*/
const COOKIE_NAME = "climperor_steam";
const SESSION_DAYS = 30;
const STEAM_OPENID = "https://steamcommunity.com/openid/login";
const STEAM_ID_PREFIX = "https://steamcommunity.com/openid/id/";
export function jsonResponse(body, status = 200, extraHeaders = {}) {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
...extraHeaders,
},
});
}
export function envOf(context) {
return (context && context.env) || {};
}
function b64urlEncode(bytes) {
let bin = "";
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function b64urlDecode(str) {
const pad = "=".repeat((4 - (str.length % 4)) % 4);
const b64 = (str + pad).replace(/-/g, "+").replace(/_/g, "/");
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function hmacSign(secret, message) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
return b64urlEncode(sig);
}
export function steamId64ToAccountId(steamId64) {
try {
const n = BigInt(String(steamId64));
const account = n - 76561197960265728n;
if (account <= 0n) return null;
return Number(account);
} catch {
return null;
}
}
export function parseSteamIdFromClaimedId(claimedId) {
if (!claimedId || typeof claimedId !== "string") return null;
if (!claimedId.startsWith(STEAM_ID_PREFIX)) return null;
const id = claimedId.slice(STEAM_ID_PREFIX.length).replace(/\/$/, "");
if (!/^\d{17}$/.test(id)) return null;
return id;
}
export async function createSessionToken(secret, payload) {
const body = {
...payload,
exp: Math.floor(Date.now() / 1000) + SESSION_DAYS * 86400,
};
const raw = b64urlEncode(new TextEncoder().encode(JSON.stringify(body)));
const sig = await hmacSign(secret, raw);
return `${raw}.${sig}`;
}
export async function verifySessionToken(secret, token) {
if (!secret || !token || typeof token !== "string") return null;
const parts = token.split(".");
if (parts.length !== 2) return null;
const [raw, sig] = parts;
const expect = await hmacSign(secret, raw);
if (sig.length !== expect.length) return null;
let ok = 0;
for (let i = 0; i < sig.length; i++) ok |= sig.charCodeAt(i) ^ expect.charCodeAt(i);
if (ok !== 0) return null;
try {
const json = new TextDecoder().decode(b64urlDecode(raw));
const data = JSON.parse(json);
if (!data || !data.exp || data.exp < Math.floor(Date.now() / 1000)) return null;
if (!data.steamid || !data.account_id) return null;
return data;
} catch {
return null;
}
}
export function readCookie(request, name = COOKIE_NAME) {
const header = request.headers.get("Cookie") || "";
const parts = header.split(";").map((s) => s.trim());
for (const p of parts) {
if (p.startsWith(name + "=")) {
return decodeURIComponent(p.slice(name.length + 1));
}
}
return null;
}
function sessionCookieHeader(token, { clear = false } = {}) {
if (clear) {
return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
}
const maxAge = SESSION_DAYS * 86400;
return `${COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
}
/** Add Secure on non-localhost. */
export function sessionCookieHeaderForRequest(request, token, { clear = false } = {}) {
let base = sessionCookieHeader(token, { clear });
const host = new URL(request.url).hostname;
if (host !== "127.0.0.1" && host !== "localhost") {
base = base.replace("SameSite=Lax", "Secure; SameSite=Lax");
}
return base;
}
export function steamLoginRedirectUrl(origin) {
const returnTo = `${origin}/api/auth/steam/callback`;
const params = new URLSearchParams({
"openid.ns": "http://specs.openid.net/auth/2.0",
"openid.mode": "checkid_setup",
"openid.return_to": returnTo,
"openid.realm": origin,
"openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
"openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
});
return `${STEAM_OPENID}?${params.toString()}`;
}
export async function verifySteamOpenId(query) {
const mode = query.get("openid.mode");
if (mode !== "id_res") return { ok: false, error: "bad mode" };
const claimed = query.get("openid.claimed_id");
const steamid = parseSteamIdFromClaimedId(claimed);
if (!steamid) return { ok: false, error: "bad claimed_id" };
const body = new URLSearchParams();
for (const [k, v] of query.entries()) {
if (k.startsWith("openid.")) body.set(k, v);
}
body.set("openid.mode", "check_authentication");
const res = await fetch(STEAM_OPENID, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "climperor-steam-auth",
},
body,
});
const text = await res.text();
if (!/is_valid\s*:\s*true/i.test(text)) {
return { ok: false, error: "openid invalid" };
}
return { ok: true, steamid };
}
export async function fetchSteamPersona(apiKey, steamid) {
if (!apiKey) return { personaname: null, avatar: null };
const url = new URL(
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/"
);
url.searchParams.set("key", apiKey);
url.searchParams.set("steamids", steamid);
try {
const res = await fetch(url.toString(), {
headers: { "User-Agent": "climperor-steam-auth" },
});
if (!res.ok) return { personaname: null, avatar: null };
const data = await res.json();
const players = data && data.response && data.response.players;
const p = Array.isArray(players) && players[0] ? players[0] : null;
if (!p) return { personaname: null, avatar: null };
return {
personaname: p.personaname || null,
avatar: p.avatarfull || p.avatarmedium || p.avatar || null,
};
} catch {
return { personaname: null, avatar: null };
}
}
export async function sessionFromRequest(context) {
const env = envOf(context);
const secret = (env.SESSION_SECRET || "").trim();
if (!secret) return null;
const token = readCookie(context.request);
if (!token) return null;
return verifySessionToken(secret, token);
}
+34
View File
@@ -0,0 +1,34 @@
/**
* POST|GET /api/auth/logout clear session cookie.
*/
import { jsonResponse, sessionCookieHeaderForRequest } from "./_steam_common.js";
function clear(context) {
const origin = new URL(context.request.url).origin;
const wantsHtml = (context.request.headers.get("Accept") || "").includes("text/html");
if (wantsHtml || context.request.method === "GET") {
return new Response(null, {
status: 302,
headers: {
Location: `${origin}/`,
"Set-Cookie": sessionCookieHeaderForRequest(context.request, "", { clear: true }),
"Cache-Control": "no-store",
},
});
}
return jsonResponse(
{ ok: true },
200,
{
"Set-Cookie": sessionCookieHeaderForRequest(context.request, "", { clear: true }),
}
);
}
export async function onRequestGet(context) {
return clear(context);
}
export async function onRequestPost(context) {
return clear(context);
}
+22
View File
@@ -0,0 +1,22 @@
/**
* GET /api/auth/me current Steam session (or { authenticated: false }).
*/
import { jsonResponse, sessionFromRequest } from "./_steam_common.js";
export async function onRequestGet(context) {
try {
const session = await sessionFromRequest(context);
if (!session) {
return jsonResponse({ authenticated: false });
}
return jsonResponse({
authenticated: true,
steamid: session.steamid,
account_id: session.account_id,
personaname: session.personaname || null,
avatar: session.avatar || null,
});
} catch {
return jsonResponse({ authenticated: false });
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* GET /api/auth/steam redirect to Steam OpenID login.
*/
import { envOf, steamLoginRedirectUrl } from "./_steam_common.js";
export async function onRequestGet(context) {
const env = envOf(context);
if (!(env.SESSION_SECRET || "").trim() || !(env.STEAM_API_KEY || "").trim()) {
return new Response("Steam login not configured", { status: 503 });
}
const origin = new URL(context.request.url).origin;
return Response.redirect(steamLoginRedirectUrl(origin), 302);
}
@@ -0,0 +1,56 @@
/**
* GET /api/auth/steam/callback Steam OpenID return_to.
*/
import {
createSessionToken,
envOf,
fetchSteamPersona,
sessionCookieHeaderForRequest,
steamId64ToAccountId,
verifySteamOpenId,
} from "../_steam_common.js";
export async function onRequestGet(context) {
const env = envOf(context);
const secret = (env.SESSION_SECRET || "").trim();
const apiKey = (env.STEAM_API_KEY || "").trim();
const origin = new URL(context.request.url).origin;
if (!secret || !apiKey) {
return Response.redirect(`${origin}/?auth=unconfigured`, 302);
}
const url = new URL(context.request.url);
let verified;
try {
verified = await verifySteamOpenId(url.searchParams);
} catch {
return Response.redirect(`${origin}/?auth=error`, 302);
}
if (!verified.ok) {
return Response.redirect(`${origin}/?auth=denied`, 302);
}
const steamid = verified.steamid;
const accountId = steamId64ToAccountId(steamid);
if (!accountId) {
return Response.redirect(`${origin}/?auth=error`, 302);
}
const persona = await fetchSteamPersona(apiKey, steamid);
const token = await createSessionToken(secret, {
steamid,
account_id: accountId,
personaname: persona.personaname,
avatar: persona.avatar,
});
return new Response(null, {
status: 302,
headers: {
Location: `${origin}/home`,
"Set-Cookie": sessionCookieHeaderForRequest(context.request, token),
"Cache-Control": "no-store",
},
});
}
@@ -0,0 +1,29 @@
/**
* GET /api/players/:account_id public or self profile from D1.
*/
import { sessionFromRequest } from "../auth/_steam_common.js";
import { jsonResponse, loadPlayerBundle } from "./_db.js";
export async function onRequestGet(context) {
try {
const env = context.env || {};
if (!env.DB) return jsonResponse({ error: "database not configured" }, 503);
const accountId = Number(context.params && context.params.account_id);
if (!Number.isFinite(accountId) || accountId <= 0) {
return jsonResponse({ error: "bad account_id" }, 400);
}
const session = await sessionFromRequest(context);
const self = session && Number(session.account_id) === accountId;
const bundle = await loadPlayerBundle(env.DB, accountId);
if (!bundle) return jsonResponse({ error: "not found" }, 404);
if (!self && !bundle.public_share) {
return jsonResponse({ error: "private" }, 404);
}
return jsonResponse(bundle);
} catch (e) {
return jsonResponse(
{ error: "player get failed", detail: String((e && e.message) || e) },
500
);
}
}
@@ -0,0 +1,49 @@
/**
* GET /api/players/:account_id/:match_id match detail from R2 (authz via D1).
*/
import { sessionFromRequest } from "../../auth/_steam_common.js";
import { jsonResponse } from "../_db.js";
export async function onRequestGet(context) {
try {
const env = context.env || {};
if (!env.DB) return jsonResponse({ error: "database not configured" }, 503);
const accountId = Number(context.params && context.params.account_id);
const matchId = Number(context.params && context.params.match_id);
if (!Number.isFinite(accountId) || accountId <= 0 || !Number.isFinite(matchId)) {
return jsonResponse({ error: "bad ids" }, 400);
}
const session = await sessionFromRequest(context);
const self = session && Number(session.account_id) === accountId;
const user = await env.DB.prepare(
`SELECT public_share FROM users WHERE account_id = ?`
)
.bind(accountId)
.first();
if (!user) return jsonResponse({ error: "not found" }, 404);
if (!self && !user.public_share) return jsonResponse({ error: "private" }, 404);
const row = await env.DB.prepare(
`SELECT r2_key FROM player_matches WHERE account_id = ? AND match_id = ?`
)
.bind(accountId, matchId)
.first();
const key = (row && row.r2_key) || `matches/${matchId}.json`;
if (!env.MATCHES) return jsonResponse({ error: "storage not configured" }, 503);
const obj = await env.MATCHES.get(key);
if (!obj) return jsonResponse({ error: "match not found" }, 404);
const text = await obj.text();
return new Response(text, {
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
},
});
} catch (e) {
return jsonResponse(
{ error: "match get failed", detail: String((e && e.message) || e) },
500
);
}
}
+151
View File
@@ -0,0 +1,151 @@
/** D1 helpers for Pages Functions (subset of cloudflare/player-sync/src/db.js). */
export function utcNow() {
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
}
export function jsonResponse(body, status = 200, extra = {}) {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
...extra,
},
});
}
export async function loadPlayerBundle(db, accountId) {
if (!db) return null;
const user = await db
.prepare(`SELECT * FROM users WHERE account_id = ?`)
.bind(accountId)
.first();
if (!user) return null;
const profile = await db
.prepare(`SELECT * FROM player_profiles WHERE account_id = ?`)
.bind(accountId)
.first();
const statsRows = await db
.prepare(`SELECT * FROM player_stats WHERE account_id = ?`)
.bind(accountId)
.all();
const heroes = await db
.prepare(
`SELECT * FROM player_heroes WHERE account_id = ? ORDER BY games DESC LIMIT 8`
)
.bind(accountId)
.all();
const peers = await db
.prepare(
`SELECT * FROM player_peers WHERE account_id = ? ORDER BY games DESC LIMIT 8`
)
.bind(accountId)
.all();
const recent = await db
.prepare(
`SELECT * FROM player_matches WHERE account_id = ? ORDER BY start_time DESC LIMIT 20`
)
.bind(accountId)
.all();
const statsByScope = {};
for (const row of (statsRows && statsRows.results) || []) {
try {
statsByScope[row.scope] = row.payload_json
? JSON.parse(row.payload_json)
: row;
} catch {
statsByScope[row.scope] = row;
}
}
return {
account_id: accountId,
personaname: user.personaname,
avatar: user.avatar,
public_share: !!user.public_share,
rank_tier: profile && profile.rank_tier,
leaderboard_rank: profile && profile.leaderboard_rank,
availability: profile
? {
status: profile.availability_status,
note: profile.availability_note,
complete: !!profile.availability_complete,
source: profile.source,
fetched_at: profile.fetched_at,
stale: false,
}
: null,
career: statsByScope.career || null,
recent_20: statsByScope.recent20 || null,
activity_180: statsByScope.recent180 || null,
top_heroes: ((heroes && heroes.results) || []).map((h) => ({
hero_id: h.hero_id,
hero_key: h.hero_key,
hero_name_loc: h.hero_name_loc,
games: h.games,
wins: h.wins,
winrate: h.winrate,
last_played: h.last_played,
})),
peers: ((peers && peers.results) || []).map((p) => ({
account_id: p.peer_account_id,
personaname: p.personaname,
avatar: p.avatar,
games: p.games,
wins: p.wins,
winrate: p.winrate,
})),
recent: ((recent && recent.results) || []).map((r) => ({
match_id: r.match_id,
start_time: r.start_time,
duration: r.duration,
won: !!r.won,
hero_id: r.hero_id,
hero_key: r.hero_key,
hero_name_loc: r.hero_name_loc,
kills: r.kills,
deaths: r.deaths,
assists: r.assists,
kda: r.kda,
gpm: r.gpm,
xpm: r.xpm,
hero_damage: r.hero_damage,
game_mode: r.game_mode,
lobby_type: r.lobby_type,
})),
updated_at: (profile && profile.updated_at) || user.last_login_at,
enriched_at: profile && profile.enriched_at,
};
}
export function isStale(fetchedAt, maxAgeMs = 10 * 60 * 1000) {
if (!fetchedAt) return true;
const t = Date.parse(fetchedAt);
if (!Number.isFinite(t)) return true;
return Date.now() - t > maxAgeMs;
}
export async function enqueueRefresh(env, payload) {
if (!env.SYNC_QUEUE) return false;
const jobId = crypto.randomUUID();
const now = utcNow();
if (env.DB) {
await env.DB.prepare(
`INSERT INTO sync_jobs (id, account_id, kind, match_id, status, attempts, created_at, updated_at)
VALUES (?, ?, ?, ?, 'queued', 0, ?, ?)`
)
.bind(
jobId,
payload.account_id,
payload.kind || "login_refresh",
payload.match_id || null,
now,
now
)
.run();
}
await env.SYNC_QUEUE.send({ ...payload, job_id: jobId });
return true;
}
+92
View File
@@ -0,0 +1,92 @@
/**
* GET /api/players/me logged-in user's profile from D1; enqueue refresh if stale.
*/
import { sessionFromRequest } from "../auth/_steam_common.js";
import {
enqueueRefresh,
isStale,
jsonResponse,
loadPlayerBundle,
} from "./_db.js";
export async function onRequestGet(context) {
try {
const session = await sessionFromRequest(context);
if (!session || !session.account_id) {
return jsonResponse({ authenticated: false }, 401);
}
const env = context.env || {};
const accountId = Number(session.account_id);
if (!env.DB) {
return jsonResponse(
{ error: "database not configured", account_id: accountId },
503
);
}
// Ensure user row exists for first login.
const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
await env.DB.prepare(
`INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at)
VALUES (?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
personaname=COALESCE(excluded.personaname, users.personaname),
avatar=COALESCE(excluded.avatar, users.avatar),
last_login_at=excluded.last_login_at`
)
.bind(
accountId,
String(session.steamid || ""),
session.personaname || null,
session.avatar || null,
now,
now
)
.run();
let bundle = await loadPlayerBundle(env.DB, accountId);
const fetchedAt =
(bundle && bundle.availability && bundle.availability.fetched_at) ||
(bundle && bundle.enriched_at) ||
null;
let stale = !bundle || isStale(fetchedAt);
if (stale) {
await enqueueRefresh(env, {
kind: "login_refresh",
account_id: accountId,
steamid: session.steamid,
personaname: session.personaname,
avatar: session.avatar,
});
}
if (!bundle) {
return jsonResponse({
authenticated: true,
account_id: accountId,
personaname: session.personaname || null,
avatar: session.avatar || null,
public_share: false,
recent: [],
career: null,
recent_20: null,
top_heroes: [],
peers: [],
activity_180: null,
availability: {
status: "unknown",
note: "正在同步…",
complete: false,
stale: true,
},
stale: true,
});
}
if (bundle.availability) bundle.availability.stale = stale;
return jsonResponse({ ...bundle, authenticated: true, stale });
} catch (e) {
return jsonResponse(
{ error: "me failed", detail: String((e && e.message) || e) },
500
);
}
}
+33 -348
View File
@@ -1,35 +1,12 @@
/**
* Pages Function: POST /api/players/publish
* POST /api/players/publish
*
* Body: { account_id, match_id }
* Optional header: X-Climperor-Publish-Secret (when PLAYER_PAGES_PUBLISH_SECRET set).
* Optional header: X-Climperor-Publish-Secret
*
* Fetches OpenDota match, verifies account_id is in the lobby, normalizes Max+-style
* JSON, merges profile.recent, PUTs to Aliyun OSS:
* players/{account_id}/profile.json
* players/{account_id}/matches/{match_id}.json
*
* Secrets (Pages env): OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET,
* optional OSS_BUCKET, OSS_ENDPOINT, PLAYER_PAGES_PUBLISH_SECRET, OPENDOTA_API_KEY.
*
* Soft-fail: match not ready 202; bad membership 403; never echo secrets.
* Enqueues Cloudflare Queue sync (D1 + R2). No longer writes OSS profile JSON.
*/
const OPENDOTA = "https://api.opendota.com/api";
const DEFAULT_BUCKET = "climperor";
const DEFAULT_ENDPOINT = "oss-cn-shanghai.aliyuncs.com";
const RECENT_LIMIT = 30;
function jsonResponse(body, status = 200, extraHeaders = {}) {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
...extraHeaders,
},
});
}
import { enqueueRefresh, jsonResponse } from "./_db.js";
function envOf(context) {
return (context && context.env) || {};
@@ -40,278 +17,14 @@ function intField(v, fallback = 0) {
return Number.isFinite(n) ? Math.trunc(n) : fallback;
}
function kda(kills, deaths, assists) {
return Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10;
}
function mvpScore(p) {
const k = intField(p.kills);
const d = intField(p.deaths);
const a = intField(p.assists);
const dmg = intField(p.hero_damage);
const nw = intField(p.net_worth) || intField(p.gold) + intField(p.gold_spent);
return (k * 1.5 + a + dmg / 1000 + nw / 2000) / Math.max(d, 1);
}
function itemIds(player) {
const out = [];
for (let i = 0; i < 6; i++) {
const id = intField(player[`item_${i}`]);
if (id > 0) out.push(id);
}
return out;
}
function accountInMatch(match, accountId) {
const players = match.players || [];
for (const p of players) {
if (p && intField(p.account_id, -1) === accountId) return true;
}
return false;
}
function utcNow() {
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
}
async function fetchJson(url, { headers } = {}) {
const res = await fetch(url, {
headers: { Accept: "application/json", "User-Agent": "climperor-publish", ...(headers || {}) },
});
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res.json();
}
async function loadHeroMap(opendotaKey) {
const q = opendotaKey ? `?api_key=${encodeURIComponent(opendotaKey)}` : "";
try {
const rows = await fetchJson(`${OPENDOTA}/heroes${q}`);
const map = new Map();
if (Array.isArray(rows)) {
for (const h of rows) {
if (!h || h.id == null) continue;
const key = String(h.name || "").replace(/^npc_dota_hero_/, "") || null;
map.set(intField(h.id), {
key,
name_loc: h.localized_name || key,
});
}
}
return map;
} catch {
return new Map();
}
}
function normalizeMatch(match, focusAccountId, heroMap) {
const playersRaw = match.players;
if (!Array.isArray(playersRaw) || !playersRaw.length) return null;
const matchId = intField(match.match_id);
if (matchId <= 0) return null;
const radiantWin = !!match.radiant_win;
const teamKills = [0, 0];
const teamNw = [0, 0];
const teamDmg = [0, 0];
const slim = [];
for (const p of playersRaw) {
if (!p || typeof p !== "object") continue;
const slot = intField(p.player_slot);
const isRadiant = slot < 128;
const side = isRadiant ? 0 : 1;
const kills = intField(p.kills);
const deaths = intField(p.deaths);
const assists = intField(p.assists);
const heroDamage = intField(p.hero_damage);
let netWorth = intField(p.net_worth);
if (netWorth <= 0) netWorth = intField(p.gold) + intField(p.gold_spent);
teamKills[side] += kills;
teamNw[side] += netWorth;
teamDmg[side] += heroDamage;
const heroId = intField(p.hero_id);
const hero = heroMap.get(heroId) || {};
let accountId = null;
if (p.account_id != null) {
const a = intField(p.account_id, -1);
accountId = a >= 0 ? a : null;
}
let personaname = typeof p.personaname === "string" ? p.personaname.trim() : null;
if (!personaname) personaname = null;
slim.push({
account_id: accountId,
personaname,
hero_id: heroId,
hero_key: hero.key || null,
hero_name_loc: hero.name_loc || hero.key || null,
level: intField(p.level),
kills,
deaths,
assists,
kda: kda(kills, deaths, assists),
hero_damage: heroDamage,
net_worth: netWorth,
items: itemIds(p),
is_radiant: isRadiant,
won: isRadiant ? radiantWin : !radiantWin,
_mvp: mvpScore(p),
_side: side,
});
}
if (slim.length < 2) return null;
for (const p of slim) {
const side = p._side;
const tk = teamKills[side] || 1;
const td = teamDmg[side] || 1;
p.participation = Math.round(((p.kills + p.assists) / tk) * 1000) / 1000;
p.damage_share = Math.round((p.hero_damage / td) * 1000) / 1000;
}
let mvp = slim[0];
for (const p of slim) {
if (p._mvp > mvp._mvp) mvp = p;
}
const mvpAccount = mvp.account_id;
for (const p of slim) {
p.is_mvp = mvpAccount != null && p.account_id === mvpAccount;
delete p._mvp;
delete p._side;
}
let startTime = null;
if (match.start_time != null) {
const t = intField(match.start_time, -1);
startTime = t >= 0 ? t : null;
}
return {
match_id: matchId,
start_time: startTime,
duration: intField(match.duration),
radiant_win: radiantWin,
radiant: { kills: teamKills[0], net_worth: teamNw[0] },
dire: { kills: teamKills[1], net_worth: teamNw[1] },
mvp_account_id: mvpAccount,
players: slim,
focus_account_id: focusAccountId,
fetched_at: utcNow(),
source: "opendota",
};
}
function summaryForProfile(detail, accountId) {
const focus = (detail.players || []).find((p) => p.account_id === accountId);
if (!focus) return null;
return {
match_id: detail.match_id,
start_time: detail.start_time,
duration: detail.duration,
won: !!focus.won,
hero_id: focus.hero_id,
hero_key: focus.hero_key,
hero_name_loc: focus.hero_name_loc,
kills: focus.kills,
deaths: focus.deaths,
assists: focus.assists,
kda: focus.kda,
};
}
async function ossGetJson(env, key) {
const bucket = env.OSS_BUCKET || DEFAULT_BUCKET;
const endpoint = env.OSS_ENDPOINT || DEFAULT_ENDPOINT;
const url = `https://${bucket}.${endpoint}/${key}`;
try {
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
async function hmacSha1Base64(secret, stringToSign) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(stringToSign));
const bytes = new Uint8Array(sig);
let bin = "";
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
async function ossPutJson(env, key, obj) {
const accessKeyId = env.OSS_ACCESS_KEY_ID;
const accessKeySecret = env.OSS_ACCESS_KEY_SECRET;
if (!accessKeyId || !accessKeySecret) {
const err = new Error("OSS credentials missing");
err.status = 503;
throw err;
}
const bucket = env.OSS_BUCKET || DEFAULT_BUCKET;
const endpoint = env.OSS_ENDPOINT || DEFAULT_ENDPOINT;
const body = JSON.stringify(obj);
const contentType = "application/json; charset=utf-8";
const date = new Date().toUTCString();
const resource = `/${bucket}/${key}`;
// Rely on bucket/prefix public-read policy (no x-oss-object-acl; some buckets disallow ACL).
const stringToSign = `PUT\n\n${contentType}\n${date}\n${resource}`;
const signature = await hmacSha1Base64(accessKeySecret, stringToSign);
const url = `https://${bucket}.${endpoint}/${key}`;
const res = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": contentType,
Date: date,
Authorization: `OSS ${accessKeyId}:${signature}`,
"Cache-Control": "public, max-age=60",
},
body,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const err = new Error(`OSS PUT ${res.status}: ${text.slice(0, 200)}`);
err.status = 502;
throw err;
}
}
function mergeProfile(existing, accountId, summary, personaname) {
const profile =
existing && typeof existing === "object"
? { ...existing }
: { account_id: accountId, personaname: null, recent: [] };
let recent = Array.isArray(profile.recent) ? profile.recent.filter((r) => r && typeof r === "object") : [];
recent = recent.filter((r) => intField(r.match_id) !== summary.match_id);
recent.unshift(summary);
profile.recent = recent.slice(0, RECENT_LIMIT);
profile.account_id = accountId;
if (personaname) profile.personaname = personaname;
profile.public_share = true;
profile.updated_at = utcNow();
return profile;
}
export async function onRequestPost(context) {
try {
const env = envOf(context);
const expected = (env.PLAYER_PAGES_PUBLISH_SECRET || "").trim();
if (expected) {
const got = (context.request.headers.get("X-Climperor-Publish-Secret") || "").trim();
const got = (
context.request.headers.get("X-Climperor-Publish-Secret") || ""
).trim();
if (got !== expected) {
return jsonResponse({ error: "forbidden" }, 403);
}
@@ -330,70 +43,42 @@ export async function onRequestPost(context) {
return jsonResponse({ error: "account_id and match_id required" }, 400);
}
const odKey = (env.OPENDOTA_API_KEY || "").trim();
const q = odKey ? `?api_key=${encodeURIComponent(odKey)}` : "";
let match;
try {
match = await fetchJson(`${OPENDOTA}/matches/${matchId}${q}`);
} catch (e) {
if (e && e.status === 404) {
return jsonResponse(
{ ok: false, pending: true, message: "match not ready on OpenDota" },
202
);
}
return jsonResponse({ error: "opendota fetch failed", detail: String(e.message || e) }, 502);
if (!env.SYNC_QUEUE) {
return jsonResponse({ error: "sync queue not configured" }, 503);
}
if (!match || !Array.isArray(match.players) || !match.players.length) {
return jsonResponse(
{ ok: false, pending: true, message: "match incomplete on OpenDota" },
202
);
// Mark public_share when PC publishes intentionally.
if (env.DB) {
const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
const steamid = String(BigInt(accountId) + 76561197960265728n);
await env.DB.prepare(
`INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at)
VALUES (?, ?, NULL, NULL, 1, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET public_share = 1, last_login_at = excluded.last_login_at`
)
.bind(accountId, steamid, now, now)
.run();
}
if (!accountInMatch(match, accountId)) {
return jsonResponse({ error: "account not in match" }, 403);
}
const heroMap = await loadHeroMap(odKey);
const detail = normalizeMatch(match, accountId, heroMap);
if (!detail) {
return jsonResponse({ error: "normalize failed" }, 500);
}
const summary = summaryForProfile(detail, accountId);
if (!summary) {
return jsonResponse({ error: "focus player missing" }, 500);
}
let personaname = null;
for (const p of detail.players) {
if (p.account_id === accountId && p.personaname) {
personaname = p.personaname;
break;
}
}
const profileKey = `players/${accountId}/profile.json`;
const matchKey = `players/${accountId}/matches/${matchId}.json`;
const existing = await ossGetJson(env, profileKey);
const profile = mergeProfile(existing, accountId, summary, personaname);
await ossPutJson(env, matchKey, detail);
await ossPutJson(env, profileKey, profile);
return jsonResponse({
ok: true,
const queued = await enqueueRefresh(env, {
kind: "publish_match",
account_id: accountId,
match_id: matchId,
public_share: true,
});
if (!queued) {
return jsonResponse({ error: "enqueue failed" }, 503);
}
return jsonResponse({
ok: true,
queued: true,
account_id: accountId,
match_id: matchId,
profile_key: profileKey,
match_key: matchKey,
});
} catch (e) {
const status = (e && e.status) || 500;
return jsonResponse(
{ error: "publish failed", detail: String((e && e.message) || e) },
status >= 400 && status < 600 ? status : 500
500
);
}
}
+14 -5
View File
@@ -43,8 +43,8 @@
}
</script>
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
<link rel="stylesheet" href="/style.css?v=0.6.16" />
<script src="/mobile-gate.js?v=0.6.16"></script>
<link rel="stylesheet" href="/style.css?v=0.6.54" />
<script src="/mobile-gate.js?v=0.6.54"></script>
</head>
<body>
<h1 class="sr-only">DOTA2 上分帝</h1>
@@ -87,6 +87,7 @@
<span class="brand-title">上分帝</span>
</div>
<nav class="main-tabs" aria-label="主分页">
<button type="button" class="main-tab hidden" data-page="home" id="tab-home"></button>
<button type="button" class="main-tab active" data-page="heroes">英雄</button>
<button type="button" class="main-tab" data-page="rankings">排行</button>
<button type="button" class="main-tab" data-page="streamers">主播</button>
@@ -99,6 +100,14 @@
<div class="topbar-tools">
<input id="q" class="search" type="search" placeholder="搜索英雄或别名" autocomplete="off" spellcheck="false" aria-label="搜索英雄" />
<input id="q-item" class="search search-inline hidden" type="search" placeholder="搜索物品" autocomplete="off" spellcheck="false" aria-label="搜索物品" />
<div class="auth-box" id="auth-box">
<a class="steam-login-btn" id="steam-login-btn" href="/api/auth/steam">Steam 登录</a>
<div class="auth-user hidden" id="auth-user">
<img class="auth-avatar" id="auth-avatar" alt="" width="28" height="28" />
<span class="auth-name" id="auth-name"></span>
<button type="button" class="auth-logout" id="auth-logout" title="退出登录">退出</button>
</div>
</div>
<a
class="contact-mail"
href="mailto:c9mhs8vfmuyv@outlook.com"
@@ -247,8 +256,8 @@
</div>
<footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer>
<script src="/config.js?v=0.6.16"></script>
<script src="/router.js?v=0.6.16"></script>
<script src="/app.js?v=0.6.16"></script>
<script src="/config.js?v=0.6.54"></script>
<script src="/router.js?v=0.6.54"></script>
<script src="/app.js?v=0.6.54"></script>
</body>
</html>
+13 -1
View File
@@ -4,7 +4,7 @@
* Path-based router for the Climperor web site (web/frontend).
*
* Synchronizes the browser URL with app state across these dimensions:
* - page: heroes | rankings | matches | streamers | trends | mechanics | items | patches | players
* - page: home | heroes | rankings | matches | streamers | trends | mechanics | items | patches | players
* - hero: selected hero key + detail sub-tab
* (skills|core|fears|trends|matchups|matches|streamers|patches; legacy stats trends)
* - rankings: Immortal leaderboard region
@@ -12,6 +12,8 @@
* - matches: star-player recent matches (pro_matches)
* /matches[/account_id][?origin=pro|china][&page=N]
* (default origin all omitted; page=1 omitted)
* - home: logged-in Steam self homepage (players view + auth.account_id)
* /home[/{match_id}]
* - players: PC post-match player home + match detail (local/OSS JSON)
* /players/{account_id}[/{match_id}]
* - streamers: curated streamer directory
@@ -41,6 +43,7 @@
const ROUTE_DEFAULT = "/heroes";
const VALID_PAGES = [
"home",
"heroes",
"rankings",
"matches",
@@ -161,6 +164,11 @@ function parseHash(hashOrPath) {
if (segs[1] && /^\d+$/.test(segs[1])) {
out.matchesPlayerId = segs[1];
}
} else if (page === "home") {
// Logged-in self homepage; optional match detail: /home/{match_id}
if (segs[1] && /^\d+$/.test(segs[1])) {
out.playerMatchId = segs[1];
}
} else if (page === "players") {
if (segs[1] && /^\d+$/.test(segs[1])) {
out.playerAccountId = segs[1];
@@ -240,6 +248,10 @@ function serializeHash(state) {
if (state.matchesPlayerId) {
path += "/" + encodeURIComponent(String(state.matchesPlayerId));
}
} else if (state.page === "home") {
if (state.playerMatchId) {
path += "/" + encodeURIComponent(String(state.playerMatchId));
}
} else if (state.page === "players") {
if (state.playerAccountId) {
path += "/" + encodeURIComponent(String(state.playerAccountId));
+656 -61
View File
@@ -65,6 +65,8 @@
/* Matches list + right filter aside (centered column must leave aside room). */
--content-data: 1200px;
--aside-gap: 20px;
/* Patches "AI 解读": grows with right gutter up to this cap. */
--patches-aside-max: 420px;
--focus-ring: 2px solid var(--sel);
}
@@ -106,6 +108,8 @@ body {
.tagbar button:focus-visible,
.search:focus-visible,
.rankings-region-btn:focus-visible,
.steam-login-btn:focus-visible,
.auth-logout:focus-visible,
.contact-mail:focus-visible,
.mobile-demand-btn:focus-visible,
#patch-select:focus-visible {
@@ -232,6 +236,79 @@ body {
width: 44px;
height: 44px;
}
.auth-box {
display: flex;
align-items: center;
gap: var(--space-xs);
flex: 0 0 auto;
min-height: 44px;
}
.steam-login-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
font: inherit;
font-size: var(--fs-body-sm);
line-height: var(--lh-body);
letter-spacing: 0.04em;
text-decoration: none;
white-space: nowrap;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.steam-login-btn:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.08);
border-color: rgba(140, 170, 210, 0.35);
}
.auth-user {
display: flex;
align-items: center;
gap: var(--space-xs);
min-width: 0;
}
.auth-avatar {
width: 28px;
height: 28px;
border-radius: var(--radius-full);
object-fit: cover;
flex: 0 0 auto;
background: rgba(255, 255, 255, 0.06);
}
.auth-name {
max-width: 9em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
font-size: var(--fs-body-sm);
line-height: var(--lh-body);
}
.auth-logout {
min-height: 32px;
padding: 0 8px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--muted);
font: inherit;
font-size: var(--fs-label-sm);
letter-spacing: 0.04em;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.auth-logout:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.06);
}
.auth-home-gate .steam-login-btn {
margin-top: var(--space-sm);
}
.contact-mail {
display: flex;
align-items: center;
@@ -278,7 +355,7 @@ body {
cursor: pointer;
transition: color 0.15s;
}
.main-tab + .main-tab::before {
.main-tab:not(.hidden) + .main-tab:not(.hidden)::before {
content: "";
position: absolute;
top: 10px;
@@ -443,6 +520,7 @@ body.detail-drawer-open .hero-role-toolbar {
}
@media (max-width: 900px) {
.brand-title { display: none; }
.auth-name { display: none; }
.main-tab {
min-width: 56px;
padding: 10px 10px;
@@ -2919,7 +2997,12 @@ body:has(#items-view:not(.hidden)) {
left: calc(100% + var(--aside-gap));
top: 0;
bottom: 0;
width: 280px;
/* Fill available right gutter (centered 820px main); floor 280, cap 420. */
width: clamp(
280px,
calc((100vw - var(--content-read)) / 2 - var(--aside-gap) - 24px),
var(--patches-aside-max)
);
pointer-events: none;
box-sizing: border-box;
}
@@ -3047,7 +3130,7 @@ button.patch-summary-chip.is-clickable:hover .patch-summary-chip-name {
color: var(--muted);
line-height: 1.4;
}
/* Need ~820 main + 300 aside gutter on each side of optical center. */
/* Need ~820 main + ~300 aside gutter on each side of optical center. */
@media (max-width: 1440px) {
.patches-center-wrap {
display: flex;
@@ -4423,9 +4506,11 @@ html.mobile-client #mobile-gate {
/* —— PC post-match player pages (/players) —— */
.players-board .players-cluster {
max-width: 1100px;
width: 100%;
max-width: 1224px;
margin: 0 auto;
padding: var(--space-lg) var(--space-md) 48px;
box-sizing: border-box;
}
.players-body {
display: flex;
@@ -4435,27 +4520,301 @@ html.mobile-client #mobile-gate {
.players-back {
appearance: none;
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 36px;
margin: 0;
padding: 0 14px 0 10px;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
font: inherit;
font-size: 13px;
padding: 6px 12px;
border-radius: var(--radius-md);
cursor: pointer;
}
.players-back:hover {
background: var(--surface-raised);
color: var(--text);
border-color: rgba(94, 200, 255, 0.45);
font: inherit;
font-size: 14px;
font-weight: var(--fw-semibold);
line-height: 1;
cursor: pointer;
white-space: nowrap;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.players-profile-head,
.players-match-head {
.players-back-icon {
flex: 0 0 auto;
display: block;
opacity: 0.9;
}
.players-back:hover,
.players-back:focus-visible {
color: var(--accent, #5ec8ff);
border-color: rgba(94, 200, 255, 0.5);
background: rgba(94, 200, 255, 0.08);
outline: none;
}
.players-profile-head {
margin-bottom: var(--space-sm);
}
.players-home {
width: 100%;
margin: 0 auto;
padding: var(--space-md) 0 var(--space-xl);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.players-identity {
display: flex;
align-items: center;
gap: var(--space-md);
padding: 12px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--panel-soft);
}
.players-identity-avatar {
width: 56px;
height: 56px;
border-radius: var(--radius-full);
object-fit: cover;
flex: 0 0 auto;
background: rgba(255, 255, 255, 0.06);
}
.players-identity-body {
min-width: 0;
flex: 1 1 auto;
}
.players-profile-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
}
.players-profile-title .page-title {
margin: 0;
}
.players-identity .page-sub {
margin: 4px 0 0;
}
.rank-medal {
position: relative;
display: inline-block;
width: 36px;
height: 36px;
flex: 0 0 auto;
line-height: 0;
}
.rank-medal-base,
.rank-medal-stars {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
}
/* OpenDota medals keep ~1520% canvas padding; size the box to match the 56px avatar so the glyph reads at similar optical weight. */
.players-rank-medal {
width: 64px;
height: 64px;
margin-inline: -4px;
}
.players-rank-board {
color: var(--muted);
font-size: var(--fs-body-sm);
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
}
.match-rank-medal {
width: 28px;
height: 28px;
}
.players-section-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: var(--fw-semibold);
letter-spacing: 0.06em;
color: var(--muted);
text-transform: none;
}
.players-snapshot {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-md);
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--panel-soft);
}
.players-snapshot-col {
min-width: 0;
}
.players-stat-row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
}
.players-stat-card {
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-raised);
display: flex;
flex-direction: column;
gap: 2px;
}
.players-stat-label {
color: var(--muted);
font-size: 11px;
letter-spacing: 0.04em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.players-stat-value {
font-variant-numeric: tabular-nums;
font-size: 16px;
font-weight: var(--fw-semibold);
color: var(--text);
line-height: 1.2;
}
.players-analysis {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
gap: var(--space-md);
}
.players-analysis-side {
display: flex;
flex-direction: column;
gap: var(--space-md);
min-width: 0;
}
.players-analysis-block {
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--panel-soft);
min-width: 0;
}
.players-top-heroes {
display: flex;
flex-direction: column;
gap: 6px;
}
.players-peers {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 6px;
}
.players-top-hero,
.players-peer {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: var(--radius-md);
background: var(--surface-raised);
color: inherit;
text-align: left;
font: inherit;
cursor: default;
min-width: 0;
}
.players-peer {
cursor: pointer;
border-color: var(--border);
}
.players-peer:hover {
border-color: rgba(94, 200, 255, 0.4);
}
.players-top-hero img {
width: 48px;
height: 27px;
object-fit: cover;
border-radius: var(--radius-sm);
flex: 0 0 auto;
}
.players-peer img {
width: 28px;
height: 28px;
border-radius: var(--radius-full);
object-fit: cover;
flex: 0 0 auto;
}
.players-top-hero > div,
.players-peer > div {
min-width: 0;
}
.players-top-hero strong,
.players-peer strong {
display: block;
font-size: var(--fs-body-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.players-top-hero span,
.players-peer span {
color: var(--muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.players-highs {
color: var(--muted);
font-size: 12px;
margin: 0 0 8px;
font-variant-numeric: tabular-nums;
}
.players-heatmap {
display: grid;
grid-template-rows: repeat(7, 9px);
grid-auto-flow: column;
grid-auto-columns: 9px;
gap: 2px;
max-width: 100%;
overflow: hidden;
}
.players-heatmap-wrap {
display: flex;
align-items: flex-end;
justify-content: flex-start;
gap: 12px;
}
.players-heatmap-legend {
display: flex;
align-items: center;
gap: 3px;
color: var(--muted);
font-size: 11px;
white-space: nowrap;
}
.players-heatmap-legend .players-heat-cell {
display: inline-block;
flex: 0 0 auto;
}
.players-heat-cell {
width: 9px;
height: 9px;
border-radius: 2px;
background: rgba(140, 170, 210, 0.12);
}
.players-heat-cell.lv1 { background: rgba(61, 206, 122, 0.25); }
.players-heat-cell.lv2 { background: rgba(61, 206, 122, 0.45); }
.players-heat-cell.lv3 { background: rgba(61, 206, 122, 0.7); }
.players-heat-cell.lv4 { background: rgba(61, 206, 122, 0.95); }
.players-recent-section {
margin-top: 4px;
}
.players-recent-section > .players-section-title {
margin-bottom: 10px;
color: var(--text);
font-size: 16px;
letter-spacing: 0;
}
.players-recent {
display: flex;
flex-direction: column;
gap: 8px;
gap: 4px;
}
.players-recent-row {
appearance: none;
@@ -4468,12 +4827,14 @@ html.mobile-client #mobile-gate {
background: var(--surface-raised);
color: var(--text);
border-radius: var(--radius-md);
padding: 10px 12px;
padding: 8px 12px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.players-recent-row:hover {
border-color: rgba(94, 200, 255, 0.4);
.players-recent-row:hover,
.players-recent-row:focus-visible {
border-color: rgba(94, 200, 255, 0.45);
outline: none;
}
.players-recent-row.won {
border-left: 3px solid var(--good);
@@ -4482,49 +4843,186 @@ html.mobile-client #mobile-gate {
border-left: 3px solid var(--danger);
}
.players-recent-portrait {
width: 64px;
height: 36px;
width: 56px;
height: 32px;
object-fit: cover;
border-radius: 4px;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.players-recent-body {
flex: 1;
min-width: 0;
display: grid;
/* hero/KDA grows left | duration/when + result/id tight on the right */
grid-template-columns: minmax(0, 1fr) auto auto;
column-gap: 20px;
align-items: center;
}
.players-recent-top,
.players-recent-bot {
display: flex;
justify-content: space-between;
gap: 12px;
.players-recent-hero-col,
.players-recent-meta,
.players-recent-end {
display: grid;
grid-template-rows: 1.3em 1.15em;
row-gap: 2px;
align-items: center;
min-width: 0;
}
.players-recent-top {
.players-recent-hero-col {
min-width: 0;
}
.players-recent-meta {
justify-items: end;
flex-shrink: 0;
}
.players-recent-end {
justify-items: end;
flex-shrink: 0;
}
.players-recent-hero {
font-size: 14px;
font-weight: 700;
line-height: 1.3em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.players-recent-bot {
margin-top: 4px;
.players-recent-kda {
font-size: 12px;
line-height: 1.15em;
color: var(--muted);
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.players-recent-dur {
font-size: 14px;
font-weight: 600;
line-height: 1.2;
font-variant-numeric: tabular-nums;
white-space: nowrap;
color: var(--text);
}
.players-recent-when {
font-size: 12px;
line-height: 1.2;
color: var(--muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.players-recent-wl {
font-size: 13px;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1.3em;
white-space: nowrap;
}
.players-recent-id {
font-size: 12px;
line-height: 1.15em;
color: var(--muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.players-recent-wl.won {
color: var(--good);
}
.players-recent-wl.lost {
color: var(--danger);
}
@media (max-width: 980px) {
.players-snapshot {
grid-template-columns: 1fr;
}
.players-analysis {
grid-template-columns: 1fr;
}
.players-stat-row {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.players-board .players-cluster {
padding-inline: 12px;
}
.players-recent-body {
column-gap: 14px;
}
}
.players-match-shell {
width: 100%;
margin: 0 auto;
padding: var(--space-md) 0 var(--space-xl);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.players-match-shell > .players-back {
margin-bottom: -4px;
}
.players-match-head {
margin: 0;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--panel-soft);
}
.players-match-summary .page-title {
margin: 0;
font-size: 18px;
}
.players-match-summary .page-sub {
margin: 6px 0 0;
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
font-variant-numeric: tabular-nums;
}
.players-match-winner {
color: var(--good);
font-weight: 700;
}
.player-scoreboard {
display: flex;
flex-direction: column;
gap: 18px;
gap: 12px;
}
.player-scoreboard-columns,
.player-match-row {
/* name capped | metrics fill remaining | items fixed */
grid-template-columns: minmax(11rem, 16rem) minmax(0, 1fr) 14.5rem;
}
.player-scoreboard-columns {
display: grid;
column-gap: 12px;
align-items: center;
padding: 0 12px;
color: var(--muted);
font-size: 11px;
letter-spacing: 0.04em;
}
.player-scoreboard-metric-labels,
.player-match-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
column-gap: 12px;
width: 100%;
justify-self: stretch;
box-sizing: border-box;
}
.player-scoreboard-items-label {
text-align: right;
}
.player-team {
border: 1px solid var(--border);
border-radius: var(--radius-md);
border-radius: var(--radius-lg);
background: var(--panel-soft);
overflow: hidden;
}
.player-team.radiant {
.player-team.won {
border-color: rgba(61, 206, 122, 0.35);
}
.player-team.dire {
.player-team.lost {
border-color: rgba(232, 106, 106, 0.35);
}
.player-team-head {
@@ -4532,13 +5030,13 @@ html.mobile-client #mobile-gate {
justify-content: space-between;
align-items: baseline;
gap: 12px;
padding: 10px 14px;
font-size: 14px;
padding: 8px 12px;
font-size: 13px;
}
.player-team.radiant .player-team-head {
.player-team.won .player-team-head {
background: rgba(61, 206, 122, 0.12);
}
.player-team.dire .player-team-head {
.player-team.lost .player-team-head {
background: rgba(232, 106, 106, 0.12);
}
.player-team-name {
@@ -4547,7 +5045,8 @@ html.mobile-client #mobile-gate {
}
.player-team-stats {
color: var(--muted);
font-size: 13px;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.player-team-rows {
display: flex;
@@ -4555,10 +5054,9 @@ html.mobile-client #mobile-gate {
}
.player-match-row {
display: grid;
grid-template-columns: minmax(160px, 1.2fr) minmax(220px, 1.4fr) auto;
gap: 12px;
column-gap: 12px;
align-items: center;
padding: 10px 14px;
padding: 8px 12px;
border-top: 1px solid var(--border);
}
.player-match-row.is-focus {
@@ -4574,24 +5072,48 @@ html.mobile-client #mobile-gate {
min-width: 0;
}
.player-match-portrait {
width: 72px;
height: 40px;
width: 64px;
height: 36px;
object-fit: cover;
border-radius: 4px;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.player-match-meta {
min-width: 0;
flex: 1 1 auto;
}
.player-match-name {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
min-width: 0;
}
a.player-match-name-link,
.player-match-name-anon {
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
a.player-match-name-link {
color: inherit;
text-decoration: none;
border-bottom: 1px solid transparent;
}
a.player-match-name-link:hover {
color: var(--accent, #5ec8ff);
border-bottom-color: rgba(94, 200, 255, 0.45);
}
.player-match-name-anon {
color: var(--muted, #8fa3bc);
font-weight: 600;
}
.players-enrich-status {
margin: 0;
font-size: 13px;
}
.player-mvp-badge {
flex-shrink: 0;
font-size: 10px;
@@ -4606,28 +5128,78 @@ html.mobile-client #mobile-gate {
margin-top: 2px;
font-size: 12px;
color: var(--muted);
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.player-match-sub > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-match-badges {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.player-party-badge {
flex-shrink: 0;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.06em;
border-radius: 3px;
padding: 1px 5px;
color: #0b1018;
background: #8fa3bc;
}
.player-party-badge.party-a {
background: #5ec8ff;
}
.player-party-badge.party-b {
background: #e8c878;
}
.player-party-badge.party-c {
background: #9b7ebd;
}
.player-party-badge.party-d {
background: #3dce7a;
}
.player-match-metrics {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
align-items: baseline;
font-size: 13px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.player-match-metrics > span {
display: inline-flex;
align-items: baseline;
justify-content: center;
gap: 4px;
white-space: nowrap;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.player-scoreboard-metric-labels > span {
text-align: center;
}
.player-match-metrics em {
font-style: normal;
color: var(--muted);
margin-right: 4px;
font-size: 11px;
display: none;
}
.player-match-items {
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: flex-end;
display: grid;
grid-template-columns: repeat(6, 34px);
gap: 3px;
justify-content: end;
width: 14.5rem;
box-sizing: border-box;
}
.player-match-item {
width: 36px;
height: 28px;
width: 34px;
height: 26px;
border-radius: 3px;
background: rgba(8, 12, 20, 0.55);
overflow: hidden;
@@ -4643,11 +5215,34 @@ html.mobile-client #mobile-gate {
.player-match-item.empty {
opacity: 0.35;
}
@media (max-width: 900px) {
@media (max-width: 1100px) {
.player-scoreboard-columns,
.player-match-row {
grid-template-columns: minmax(10rem, 14rem) minmax(0, 1fr) 14.5rem;
}
}
@media (max-width: 760px) {
.player-scoreboard-columns {
display: none;
}
.player-match-row {
grid-template-columns: 1fr;
gap: 8px;
}
.player-match-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 10px;
row-gap: 4px;
width: 100%;
justify-self: stretch;
}
.player-match-metrics em {
display: inline;
font-style: normal;
color: var(--muted);
font-size: 11px;
flex-shrink: 0;
}
.player-match-items {
justify-content: flex-start;
}
+20
View File
@@ -0,0 +1,20 @@
# Cloudflare Pages project bindings for Climperor Web.
# Deploy still uses web/deploy_relations.py (direct upload). Bindings below
# are applied via `wrangler pages project ...` / dashboard / provision script.
name = "climperor-relations"
compatibility_date = "2024-11-01"
pages_build_output_dir = "../dist/relations"
[[d1_databases]]
binding = "DB"
database_name = "climperor-users"
database_id = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
[[r2_buckets]]
binding = "MATCHES"
bucket_name = "climperor-player-data"
[[queues.producers]]
binding = "SYNC_QUEUE"
queue = "climperor-player-sync"