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:
+910
-101
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 ~15–20% 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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user