Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bc2d1cb
Open the player profile from /player/<id> links
iiamlewis Oct 3, 2026
f6c2dcf
Put the not-found buttons side by side where there's room
iiamlewis Oct 3, 2026
9df3cab
Leave the profile link when a hash-opened modal takes over
iiamlewis Oct 3, 2026
cb1188c
Let profile-share open its actions as a menu, with a line of its own
iiamlewis Oct 3, 2026
19ccf82
Share a milestone or Legend from the end-of-game popup
iiamlewis Oct 3, 2026
b67eb66
Share the new rank from the prestige ceremony
iiamlewis Oct 3, 2026
a18d74e
Drop the "New to OpenFront?" banner from the profile page
iiamlewis Oct 4, 2026
9b4481d
Flash a ring around a share button on hover or keyboard focus
iiamlewis Oct 4, 2026
c6383ae
Merge profile-progression: real prestige rewards, ceremony polish, Le…
iiamlewis Oct 4, 2026
17a3aa5
Merge profile-progression into profile-page
iiamlewis Oct 4, 2026
fbccf9d
Add a share row to the Legend ceremony
iiamlewis Oct 4, 2026
720dc89
Merge profile-progression: lazy-loaded levels screens
iiamlewis Oct 5, 2026
d1f20b9
Merge profile-progression: src/core split into packages
iiamlewis Oct 6, 2026
e274881
Merge remote-tracking branch 'origin/profile-progression' into profil…
iiamlewis Oct 6, 2026
426de1c
Merge remote-tracking branch 'origin/profile-progression' into profil…
iiamlewis Oct 8, 2026
2b637e9
Refuse share moments the card route wouldn't draw, and move the URL o…
iiamlewis Oct 8, 2026
5a83a15
Merge profile-progression (review fixes)
iiamlewis Oct 8, 2026
c1f4998
Merge profile-progression (main sync)
iiamlewis Oct 9, 2026
cc14732
Merge branch 'profile-progression' into profile-page
iiamlewis Oct 9, 2026
259f18c
Merge branch 'profile-progression' into profile-page
iiamlewis Oct 9, 2026
d178f56
Merge branch 'profile-progression' into profile-page
iiamlewis Oct 9, 2026
57d1959
Merge branch 'profile-progression' into profile-page
iiamlewis Oct 9, 2026
58e24eb
Merge profile-progression (closed-profile tab fix)
iiamlewis Oct 9, 2026
68b20ee
Merge profile-progression into profile-page
iiamlewis Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1732,11 +1732,19 @@
"milestones": "Milestones",
"no_clans": "This player isn't in any clans.",
"no_prestige": "Hasn't prestiged yet",
"not_found": "Player profile not found",
"not_found_body": "We couldn't find a player at this link. It may be mistyped, or the account may have been deleted.",
"not_found_title": "Player not found",
"play": "Play OpenFront",
"prestige_history": "Prestige history",
"share": "Share Profile",
"share_copy_link": "Copy link",
"share_discord": "Copy for Discord",
"share_native": "Share",
"share_text": "Check out {name}'s profile on OpenFront",
"share_x": "Share on X",
"title": "Player Profile",
"view": "View Profile"
"view": "View Profile",
"view_leaderboard": "View leaderboard"
},
"player_stats_table": {
"arrived": "Arrived",
Expand Down Expand Up @@ -1838,6 +1846,7 @@
"keep_holding": "Keep holding…",
"load_failed": "Couldn't open Prestige. Check your connection and try again.",
"ready": "Level 100! Until you prestige, XP only adds to your lifetime total.",
"share_text": "I just reached Prestige {rank} on OpenFront!",
"stale": "Your level changed since this page loaded, so your account has been refreshed.",
"stale_failed": "Your level changed since this page loaded. Reload the page to see where you are.",
"submitting": "Prestiging…",
Expand Down Expand Up @@ -1912,6 +1921,10 @@
"penalty_line": "−{percent}% XP ({source})",
"prestige": "Prestige {prestige}",
"prestige_short": "P{prestige}",
"share_moment_legend": "Share Legend",
"share_moment_level": "Share Level {level}",
"share_text_legend": "I just became a Legend on OpenFront!",
"share_text_level": "I just reached Level {level} on OpenFront!",
"sign_in_to_earn": "Sign in to earn XP and level up.",
"skip_reveal": "Skip",
"tap_to_skip": "Tap to skip",
Expand Down
1 change: 1 addition & 0 deletions src/client/AccountModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export class AccountModal extends BaseModal {
<div class="p-6">${this.renderTab(tab)}</div>
</div>
<prestige-flow
.publicId=${this.userMeResponse?.player?.publicId ?? ""}
@prestiged=${this.handlePrestiged}
@prestige-stale=${this.handlePrestigeStale}
></prestige-flow>
Expand Down
7 changes: 7 additions & 0 deletions src/client/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ import {
installDoubleTapZoomBlocker,
installSafariPinchZoomBlocker,
} from "./utilities/DisableSafariPinchZoom";
import { playerProfileRouteArgs } from "./utilities/PlayerProfileUrl";

import "./components/DesktopNavBar";
import "./components/Footer";
Expand Down Expand Up @@ -475,6 +476,8 @@ class Client {
tag: "player-profile-modal",
pageId: "page-profile",
});
// The canonical profile link, `/player/<id>` (see playerProfileUrl).
modalRouter.registerPath("profile", playerProfileRouteArgs);
modalRouter.register("help", { tag: "help-modal", pageId: "page-help" });
modalRouter.register("news", { tag: "news-modal", pageId: "page-news" });
modalRouter.register("language", {
Expand Down Expand Up @@ -1398,6 +1401,10 @@ class Client {
console.log(`${spectate ? "spectating" : "joining"} lobby ${lobbyId}`);
return;
}
// A shared profile link (`/player/<id>`) opens that profile.
if (modalRouter.routeFromPath()) {
return;
}
if (modalRouter.routeFromHash()) {
return;
}
Expand Down
103 changes: 101 additions & 2 deletions src/client/ModalRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,22 @@
*
* Lobby modals (join/host) and matchmaking are intentionally not registered:
* they have their own URL state (path-based) or none at all.
*
* A modal can also be opened by a path link (`/player/<id>`, see
* registerPath). While it stays open the URL keeps that path, with tab
* changes written to `?tab=`; closing it returns the URL to the home page, so
* a reload doesn't reopen it. Opening another modal, in-app or from the hash,
* hands the URL to the hash and leaves the path the same way.
*/

import { loadModal } from "./LazyModals";
import { pagePin } from "./PagePin";

/** Parses a page location into a modal's open() args, or null if it isn't one. */
export type PathRouteParser = (location: {
pathname: string;
search: string;
}) => Record<string, unknown> | null;

interface RegistryEntry {
/** Custom element tag, e.g. "store-modal". */
Expand All @@ -34,11 +47,57 @@ class ModalRouter {
private currentName: string | null = null;
/** True while we're routing from the URL (suppress modal→URL sync). */
private routingFromUrl = false;
private pathRoutes = new Map<string, PathRouteParser>();
/** Name of the modal the URL's path (not its hash) reflects, if any. */
private pathRoutedName: string | null = null;

register(name: string, entry: RegistryEntry): void {
this.registry.set(name, entry);
}

/** Let a registered modal be opened by a path link as well as the hash. */
registerPath(name: string, parse: PathRouteParser): void {
this.pathRoutes.set(name, parse);
}

/**
* Open the modal the current path names, if one does. Returns true when it
* did (the open itself is asynchronous, as for routeFromHash).
*/
routeFromPath(): boolean {
for (const [name, parse] of this.pathRoutes) {
const entry = this.registry.get(name);
if (entry === undefined) continue;
const args = parse(window.location);
if (args === null) continue;
this.pathRoutedName = name;
void this.openRegistered(name, entry, args);
return true;
}
return false;
}

/** True while the named modal is open from a path link. */
isPathRouted(name: string): boolean {
return this.pathRoutedName === name;
}

// The path link has had its say: put the URL back to the home page. The
// home page keeps the page's version pin when it has one (see
// currentPagePath in Utils, which this mirrors without its imports). A
// `#modal=` hash stays: it's another modal's, and that modal is still open.
private leavePath(): void {
if (this.pathRoutedName === null) return;
this.pathRoutedName = null;
const commit = pagePin();
history.replaceState(
history.state,
"",
(commit === null ? "/" : `/v/${commit}/`) +
(this.isHashRouted() ? window.location.hash : ""),
);
}

/**
* Parse `window.location.hash` for `#modal=<name>&...`. If present and
* registered, open the modal with the remaining keys as args. Returns true
Expand Down Expand Up @@ -66,6 +125,11 @@ class ModalRouter {
args[key] = value;
});

// The hash names the modal now, as when one opens in-app (syncOpened).
// Left on, the path would outlive the modal it names: the router stops
// tracking that modal, so its close wouldn't clear the path, and the next
// reload would open it again.
this.leavePath();
void this.openRegistered(name, entry, args);
return true;
}
Expand All @@ -85,8 +149,14 @@ class ModalRouter {
return;
}
await customElements.whenDefined(entry.tag);
// A slow load can land after the URL has moved on to another modal.
if (this.currentHashParams().get("modal") !== name) return;
// A slow load can land after the URL has moved on to another modal. The
// URL names this one by its hash, or by its path while path-routed.
if (
this.currentHashParams().get("modal") !== name &&
this.pathRoutedName !== name
) {
return;
}

this.routingFromUrl = true;
try {
Expand All @@ -108,13 +178,22 @@ class ModalRouter {
syncOpened(name: string, args?: Record<string, unknown>): void {
if (this.routingFromUrl) return; // we're driving the modal from the URL; don't loop
if (!this.registry.has(name)) return;
// Another modal (or this one again) opened in-app: the hash takes over.
this.leavePath();
this.currentName = name;
this.writeHash(name, args);
}

/** Called by BaseModal.close() when a router-managed modal closes. */
syncClosed(name: string): void {
if (this.routingFromUrl) return;
// The path names this modal whichever modal the router last recorded, so
// its close always leaves the path.
if (this.pathRoutedName === name) {
if (this.currentName === name) this.currentName = null;
this.leavePath();
return;
}
if (this.currentName !== name) return; // not the active routed modal
this.currentName = null;
this.replaceHash("");
Expand All @@ -124,6 +203,24 @@ class ModalRouter {
syncTab(name: string, tab: string): void {
if (this.routingFromUrl) return;
if (this.currentName !== name) return;
if (this.pathRoutedName === name) {
// Opened from a path: the path stays, and the tab rides as `?tab=`.
const search = new URLSearchParams(window.location.search);
if (tab) {
search.set("tab", tab);
} else {
search.delete("tab");
}
const query = search.toString();
history.replaceState(
history.state,
"",
window.location.pathname +
(query ? `?${query}` : "") +
window.location.hash,
);
return;
}
const params = this.currentHashParams();
params.set("modal", name);
if (tab) {
Expand All @@ -138,6 +235,8 @@ class ModalRouter {
syncArgs(name: string, args: Record<string, unknown>): void {
if (this.routingFromUrl) return;
if (this.currentName !== name) return;
// The path is the state of a path-routed modal; don't add a hash to it.
if (this.pathRoutedName === name) return;
const params = this.currentHashParams();
params.set("modal", name);
for (const [key, value] of Object.entries(args)) {
Expand Down
99 changes: 99 additions & 0 deletions src/client/MomentShare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { GameXpPanelState } from "./components/GameXpPanel";
import {
isMilestoneLevel,
levelsReachedInGame,
MAX_LEVEL,
MAX_PRESTIGE,
reachedLegendThisGame,
} from "./Progression";
import { playerProfileUrl } from "./utilities/PlayerProfileUrl";
import { translateText } from "./Utils";

// A progression moment worth sharing: a milestone level, becoming a Legend,
// or a new prestige rank. Shared as the player's profile link with the moment
// named in `?moment=`, so the link unfurls into a card for it.
export type ShareMoment =
| { kind: "level"; level: number }
| { kind: "legend" }
| { kind: "prestige"; rank: number };

/**
* The moment a finished game is worth sharing for, or null. Only the server's
* result counts (never a provisional figure), and only a milestone level or
* becoming a Legend: an ordinary level-up isn't shared. A Legend beats any
* level, and of several milestones reached in one game the highest is shared.
*/
export function gameShareMoment(view: GameXpPanelState): ShareMoment | null {
if (view.kind !== "result" || !view.data.eligible) return null;
const data = view.data;
if (reachedLegendThisGame(data)) return { kind: "legend" };
const milestones = levelsReachedInGame(data)
.map((l) => l.level)
.filter(isMilestoneLevel);
if (milestones.length === 0) return null;
return { kind: "level", level: Math.max(...milestones) };
}

// The moments the site's card route draws: a level from 2 (the first one
// reached) to the last, and a prestige rank from 1 to the last. It answers
// anything else with a 400, which unfurls as no image at all.
function inRange(n: number, min: number, max: number): boolean {
return Number.isInteger(n) && n >= min && n <= max;
}

/**
* The `?moment=` value: `level50`, `legend`, `prestige3`. Null for a level or
* rank the card route wouldn't draw (a surprising server value), so the
* share is dropped rather than sending a link that unfurls without a card.
*/
export function momentSlug(moment: ShareMoment): string | null {
switch (moment.kind) {
case "level":
return inRange(moment.level, 2, MAX_LEVEL)
? `level${moment.level}`
: null;
case "legend":
return "legend";
case "prestige":
return inRange(moment.rank, 1, MAX_PRESTIGE)
? `prestige${moment.rank}`
: null;
}
}

/**
* The player's profile link with the moment: `/player/<id>?moment=level50`.
* Null when the moment has no slug (see momentSlug): nothing to share.
*/
export function momentShareUrl(
publicId: string,
moment: ShareMoment,
): string | null {
const slug = momentSlug(moment);
return slug === null ? null : `${playerProfileUrl(publicId)}?moment=${slug}`;
}

/** The line that goes with the link ("I just reached Level 50 on OpenFront!"). */
export function momentShareText(moment: ShareMoment): string {
switch (moment.kind) {
case "level":
return translateText("progression.share_text_level", {
level: moment.level,
});
case "legend":
return translateText("progression.share_text_legend");
case "prestige":
return translateText("prestige.share_text", { rank: moment.rank });
}
}

/** The share button's label for a game's moment ("Share Level 50"). */
export function momentShareLabel(
moment: Exclude<ShareMoment, { kind: "prestige" }>,
): string {
return moment.kind === "legend"
? translateText("progression.share_moment_legend")
: translateText("progression.share_moment_level", {
level: moment.level,
});
}
Loading
Loading