further changes to backend for adjusting points, showing timestamps, and how categories are listed. uploaded build of react and actual frontend source
This commit is contained in:
@@ -18,6 +18,10 @@ All paths below are under `https://<backend>/racetimer_endpoints-1.0/api/`.
|
||||
|
||||
The settings path can also be given with `-Dracetimer.settings=/path/file.json` or the `RACETIMER_SETTINGS` environment variable.
|
||||
|
||||
## Category numbers
|
||||
|
||||
`categoryNumber` is counted per map and stage. Categories with exactly the same `server_cvars` share a number, so the same settings on ZE1 and ZE2 are both Category 1 and are told apart by `serverTag`. Different settings are numbered in order of their lowest `cvars_hash`. Hidden servers (dev) never take a number away from ze1/ze2.
|
||||
|
||||
## Points
|
||||
|
||||
Points are calculated per category, from each player's **best valid** time only. Tied times share a position.
|
||||
@@ -27,7 +31,8 @@ Points are calculated per category, from each player's **best valid** time only.
|
||||
| Who gets points | Everyone while a board has fewer than 200 completions. From 200 on, only the faster half (200 → top 100, 210 → top 105). |
|
||||
| Base points | n − (position − 1): the fastest gets n (the number of completions), each position below gets one less. The slower half gets 0, so on big boards points drop from about n/2 straight to 0 at the cut. |
|
||||
| Bonus multiplier | Only on boards with 100+ completions. 4× at #1, sliding smoothly to 2× at the top-1% mark, then to 1× at the top-5% mark. |
|
||||
| CLASSIC RACETIMER | Final points ÷ 10. |
|
||||
| CLASSIC RACETIMER | Final points ÷ 10, rounded. |
|
||||
| Small-board touch-ups | Everyone who gets points gets at least 1, and each of the top 10 places gets at least 1 point more than the place below it (ties share). This only ever raises points, and in practice only changes Classic boards: a 9-record Classic board gives 9, 8, 7 … 1 instead of 1, 1, 1, 1, 1, 0, 0, 0, 0. Raised entries have `pointsRaised: true`. |
|
||||
| Invalid category | Still shown with positions, but gives 0 points. The admin and time are public (`invalidation`). |
|
||||
| Invalid record | Still listed on the board in time order with `isInvalid: true`, position 0 and 0 points. Only that one run is removed from the ranking; the player's previous valid time counts again. The admin and time are public (`invalidation`). |
|
||||
| Servers | ZE1 and ZE2 points and ranks are separate. Categories from any other tag (e.g. `dev`) are hidden. |
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Stores which admin marked a record or category invalid, and when.
|
||||
-- Run once on the racetimer database before deploying the new WAR.
|
||||
-- Restoring (making it valid again) clears these columns.
|
||||
|
||||
ALTER TABLE unloze_racetimer_css_2026.timer_records
|
||||
ADD COLUMN invalidated_by_steam VARCHAR(32) NULL DEFAULT NULL AFTER is_invalid,
|
||||
ADD COLUMN invalidated_by_name VARCHAR(128) CHARACTER SET utf8mb4 NULL DEFAULT NULL AFTER invalidated_by_steam,
|
||||
ADD COLUMN invalidated_at TIMESTAMP NULL DEFAULT NULL AFTER invalidated_by_name;
|
||||
|
||||
ALTER TABLE unloze_racetimer_css_2026.zone_categories
|
||||
ADD COLUMN invalidated_by_steam VARCHAR(32) NULL DEFAULT NULL AFTER is_invalid,
|
||||
ADD COLUMN invalidated_by_name VARCHAR(128) CHARACTER SET utf8mb4 NULL DEFAULT NULL AFTER invalidated_by_steam,
|
||||
ADD COLUMN invalidated_at TIMESTAMP NULL DEFAULT NULL AFTER invalidated_by_name;
|
||||
@@ -140,6 +140,8 @@ public final class Dto {
|
||||
public int points;
|
||||
/** 1.0 = no bonus. Up to 4.0 for #1 on boards with 100+ completions. */
|
||||
public double bonusMultiplier;
|
||||
/** Points were raised so small Classic boards keep 1 point between the top 10 places (and at least 1). */
|
||||
public boolean pointsRaised;
|
||||
/** Unix seconds. Null for legacy records (date unknown). */
|
||||
public Long recordedAt;
|
||||
public boolean isLegacy;
|
||||
@@ -189,6 +191,8 @@ public final class Dto {
|
||||
public int completions;
|
||||
public double bonusMultiplier;
|
||||
public int points;
|
||||
/** See BoardEntryDTO.pointsRaised. */
|
||||
public boolean pointsRaised;
|
||||
public Long recordedAt;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,21 @@ public final class BoardEntry {
|
||||
public final int position;
|
||||
public final int points;
|
||||
public final double multiplier;
|
||||
/** Points were raised by the Classic touch-ups (minimum 1, top-10 places 1 apart). */
|
||||
public final boolean pointsRaised;
|
||||
|
||||
public BoardEntry(RecordRow record, Category category, int position, int points, double multiplier) {
|
||||
this(record, category, position, points, multiplier, false);
|
||||
}
|
||||
|
||||
public BoardEntry(RecordRow record, Category category, int position, int points, double multiplier,
|
||||
boolean pointsRaised) {
|
||||
this.record = record;
|
||||
this.category = category;
|
||||
this.position = position;
|
||||
this.points = points;
|
||||
this.multiplier = multiplier;
|
||||
this.pointsRaised = pointsRaised;
|
||||
}
|
||||
|
||||
/** An invalidated run shown on the board: no position, no points. */
|
||||
|
||||
@@ -22,7 +22,7 @@ public final class Category {
|
||||
/** Who invalidated this category and when; null if valid or unknown. */
|
||||
public Invalidation invalidation;
|
||||
|
||||
/** "Category N" exactly as the plugin numbers it in-game. */
|
||||
/** "Category N" on the website. Same server_cvars on ZE1 and ZE2 share the number. */
|
||||
public int number;
|
||||
/** Valid best per player, fastest first. */
|
||||
public List<BoardEntry> entries = new ArrayList<>();
|
||||
|
||||
@@ -108,6 +108,7 @@ public final class Views {
|
||||
d.time = e.record.time;
|
||||
d.points = e.points;
|
||||
d.bonusMultiplier = e.multiplier;
|
||||
d.pointsRaised = e.pointsRaised;
|
||||
d.isLegacy = e.category.legacy;
|
||||
d.recordedAt = e.category.legacy ? null : e.record.recordedAt;
|
||||
d.isInvalid = e.isInvalid();
|
||||
@@ -146,6 +147,7 @@ public final class Views {
|
||||
d.completions = c.completions();
|
||||
d.bonusMultiplier = e.multiplier;
|
||||
d.points = e.points;
|
||||
d.pointsRaised = e.pointsRaised;
|
||||
d.recordedAt = c.legacy ? null : e.record.recordedAt;
|
||||
d.isInvalid = e.isInvalid();
|
||||
d.invalidation = invalidation(e.isInvalid(), e.record.invalidation);
|
||||
|
||||
@@ -15,11 +15,19 @@ package racetimer.service;
|
||||
* 4x at #1 down to 2x at the top-1% mark, then 2x down to 1x at the
|
||||
* top-5% mark, 1x after that.
|
||||
* 4. CLASSIC RACETIMER (migrated) categories: final points divided by 10.
|
||||
* 5. Whole-board touch-ups (boardPoints), which only ever raise points and in
|
||||
* practice only change Classic boards, where dividing by 10 and rounding
|
||||
* would squash places together:
|
||||
* - everyone who gets points (step 1) gets at least 1;
|
||||
* - each of the top 10 places gets at least 1 point more than the place
|
||||
* below it (tied times still share their points).
|
||||
*/
|
||||
public final class Points {
|
||||
|
||||
public static final int HALF_CUTOFF_FROM = 200;
|
||||
public static final int BONUS_FROM = 100;
|
||||
/** Places 1..TOP_GAP_PLACES are always at least one point apart. */
|
||||
public static final int TOP_GAP_PLACES = 10;
|
||||
|
||||
private Points() {
|
||||
}
|
||||
@@ -62,6 +70,45 @@ public final class Points {
|
||||
}
|
||||
|
||||
/** The multiplier as shown to players, e.g. 3.2 or 1.65. */
|
||||
/**
|
||||
* Final points for a whole board. positions[i] is the position of the i-th
|
||||
* entry, fastest first (ties repeat the same position). Applies step 5 on
|
||||
* top of finalPoints. The result has the same length and order.
|
||||
*/
|
||||
public static int[] boardPoints(int[] positions, int n, boolean classic) {
|
||||
int[] pts = new int[positions.length];
|
||||
int pointed = pointedCount(n);
|
||||
for (int i = 0; i < positions.length; i++) {
|
||||
int p = finalPoints(positions[i], n, classic);
|
||||
if (positions[i] >= 1 && positions[i] <= pointed && p < 1) {
|
||||
p = 1;
|
||||
}
|
||||
pts[i] = p;
|
||||
}
|
||||
// Walk up from the bottom, one group of equal positions at a time.
|
||||
int below = 0; // points of the next lower place that earns points (0 = none)
|
||||
int i = positions.length - 1;
|
||||
while (i >= 0) {
|
||||
int j = i;
|
||||
while (j > 0 && positions[j - 1] == positions[i]) {
|
||||
j--;
|
||||
}
|
||||
int pos = positions[i];
|
||||
if (pos >= 1 && pos <= pointed) {
|
||||
int p = pts[i];
|
||||
if (pos <= TOP_GAP_PLACES && p < below + 1) {
|
||||
p = below + 1;
|
||||
}
|
||||
for (int k = j; k <= i; k++) {
|
||||
pts[k] = p;
|
||||
}
|
||||
below = p;
|
||||
}
|
||||
i = j - 1;
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
public static double displayMultiplier(int position, int n) {
|
||||
return Math.round(multiplier(position, n) * 100.0) / 100.0;
|
||||
}
|
||||
|
||||
+48
-15
@@ -68,6 +68,37 @@ public final class SnapshotBuilder {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Numbers the categories of one map+stage. Categories with exactly the same
|
||||
* server_cvars share a number, so the same settings on ZE1 and ZE2 are both
|
||||
* "Category 1" (told apart by their server tag). Distinct settings are
|
||||
* numbered by their lowest cvars_hash. Hidden servers (dev) never take a
|
||||
* number away from ze1/ze2: they reuse a matching number or come after.
|
||||
*/
|
||||
void numberByCvars(List<Category> list) {
|
||||
Collections.sort(list, new Comparator<Category>() {
|
||||
@Override
|
||||
public int compare(Category a, Category b) {
|
||||
int x = Boolean.compare(!a.ranked, !b.ranked); // public servers first
|
||||
if (x != 0) {
|
||||
return x;
|
||||
}
|
||||
x = Long.compare(a.cvarsHash, b.cvarsHash);
|
||||
return x != 0 ? x : Integer.compare(a.id, b.id);
|
||||
}
|
||||
});
|
||||
Map<String, Integer> numbers = new HashMap<>();
|
||||
for (Category c : list) {
|
||||
String key = c.serverCvars == null ? "" : c.serverCvars.trim();
|
||||
Integer n = numbers.get(key);
|
||||
if (n == null) {
|
||||
n = numbers.size() + 1;
|
||||
numbers.put(key, n);
|
||||
}
|
||||
c.number = n;
|
||||
}
|
||||
}
|
||||
|
||||
private final Set<String> rankedTags;
|
||||
private final String classicServerCvars;
|
||||
|
||||
@@ -81,7 +112,7 @@ public final class SnapshotBuilder {
|
||||
Collection<RecordRow> records,
|
||||
Map<String, List<PlayerInfo.Badge>> badges) {
|
||||
|
||||
// --- Categories, numbered like the plugin: per map+stage, ordered by cvars_hash ---
|
||||
// --- Categories, numbered per map+stage by their server settings ---
|
||||
Map<Integer, Category> categories = new HashMap<>();
|
||||
Map<String, List<Category>> byMapStage = new HashMap<>();
|
||||
for (CategoryRow r : categoryRows) {
|
||||
@@ -99,16 +130,7 @@ public final class SnapshotBuilder {
|
||||
list.add(c);
|
||||
}
|
||||
for (List<Category> list : byMapStage.values()) {
|
||||
Collections.sort(list, new Comparator<Category>() {
|
||||
@Override
|
||||
public int compare(Category a, Category b) {
|
||||
int x = Long.compare(a.cvarsHash, b.cvarsHash);
|
||||
return x != 0 ? x : Integer.compare(a.id, b.id);
|
||||
}
|
||||
});
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
list.get(i).number = i + 1;
|
||||
}
|
||||
numberByCvars(list);
|
||||
}
|
||||
|
||||
// --- Players ---
|
||||
@@ -188,6 +210,7 @@ public final class SnapshotBuilder {
|
||||
Collections.sort(sorted, byTime);
|
||||
int n = sorted.size();
|
||||
List<BoardEntry> entries = new ArrayList<>(n);
|
||||
int[] positions = new int[n];
|
||||
int position = 0;
|
||||
double previousTime = Double.NaN;
|
||||
for (int i = 0; i < n; i++) {
|
||||
@@ -196,9 +219,15 @@ public final class SnapshotBuilder {
|
||||
position = i + 1; // tied times share the position of the first one
|
||||
}
|
||||
previousTime = r.time;
|
||||
int points = c.givesPoints() ? Points.finalPoints(position, n, c.legacy) : 0;
|
||||
double mult = c.givesPoints() ? Points.displayMultiplier(position, n) : 1.0;
|
||||
BoardEntry e = new BoardEntry(r, c, position, points, mult);
|
||||
positions[i] = position;
|
||||
}
|
||||
int[] boardPoints = c.givesPoints() ? Points.boardPoints(positions, n, c.legacy) : new int[n];
|
||||
for (int i = 0; i < n; i++) {
|
||||
RecordRow r = sorted.get(i);
|
||||
int points = boardPoints[i];
|
||||
boolean raised = c.givesPoints() && points > Points.finalPoints(positions[i], n, c.legacy);
|
||||
double mult = c.givesPoints() ? Points.displayMultiplier(positions[i], n) : 1.0;
|
||||
BoardEntry e = new BoardEntry(r, c, positions[i], points, mult, raised);
|
||||
entries.add(e);
|
||||
PlayerInfo p = players.get(r.steamAuth);
|
||||
p.bests.add(e);
|
||||
@@ -292,7 +321,11 @@ public final class SnapshotBuilder {
|
||||
@Override
|
||||
public int compare(Category a, Category b) {
|
||||
int x = Integer.compare(a.stage, b.stage);
|
||||
return x != 0 ? x : Integer.compare(a.number, b.number);
|
||||
if (x != 0) {
|
||||
return x;
|
||||
}
|
||||
x = Integer.compare(a.number, b.number);
|
||||
return x != 0 ? x : a.serverTag.compareTo(b.serverTag); // same settings: ze1 before ze2
|
||||
}
|
||||
});
|
||||
for (Category c : publicCategories) {
|
||||
|
||||
@@ -102,8 +102,8 @@ public class ApiTest {
|
||||
ContainerResponse r = call("GET", "timers/player/" + A, null, null);
|
||||
assertEquals(200, r.getStatus());
|
||||
JsonObject p = json(r).getAsJsonObject();
|
||||
// CLASSIC board with 2 finishers: A is #1 -> 2 points / 10 = 0 (rounded)
|
||||
assertEquals(0, p.get("PlayerPoints").getAsInt());
|
||||
// CLASSIC board with 2 finishers: 2/10 would round to 0, but #2 gets at least 1 and #1 one more
|
||||
assertEquals(2, p.get("PlayerPoints").getAsInt());
|
||||
assertEquals("ze1", p.get("server").getAsString());
|
||||
assertEquals("76561198029832363", p.get("steamID64").getAsString());
|
||||
assertEquals(1, p.getAsJsonObject("servers").getAsJsonObject("ze2").get("rank").getAsInt());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package racetimer.service;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
@@ -72,6 +73,54 @@ public class PointsTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static int[] seq(int n) {
|
||||
int[] p = new int[n];
|
||||
for (int i = 0; i < n; i++) {
|
||||
p[i] = i + 1;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void smallClassicBoardKeepsOnePointBetweenPlaces() {
|
||||
// The real 9-record Classic board: was 1,1,1,1,1,0,0,0,0.
|
||||
assertArrayEquals(new int[] {9, 8, 7, 6, 5, 4, 3, 2, 1}, Points.boardPoints(seq(9), 9, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onlyTopTenGetTheGapButEveryoneWithPointsGetsAtLeastOne() {
|
||||
int[] p = Points.boardPoints(seq(30), 30, true);
|
||||
// Places 11..30: rounded n/10 values, at least 1.
|
||||
assertEquals(2, p[10]);
|
||||
assertEquals(1, p[29]);
|
||||
for (int i = 0; i < 10; i++) {
|
||||
assertEquals("place " + (i + 1), p[i + 1] + 1 <= p[i], true);
|
||||
}
|
||||
assertEquals(12, p[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tiesShareRaisedPoints() {
|
||||
int[] p = Points.boardPoints(new int[] {1, 2, 2, 4}, 4, true);
|
||||
assertArrayEquals(new int[] {3, 2, 2, 1}, p);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bigBoardsAndNewRecordsAreUnchanged() {
|
||||
for (int n : new int[] {5, 150, 1000, 1470}) {
|
||||
int[] pos = seq(n);
|
||||
int[] newPts = Points.boardPoints(pos, n, false);
|
||||
for (int i = 0; i < n; i++) {
|
||||
assertEquals(Points.finalPoints(i + 1, n, false), newPts[i]);
|
||||
}
|
||||
}
|
||||
int[] classic = Points.boardPoints(seq(1470), 1470, true);
|
||||
for (int i = 0; i < 735; i++) {
|
||||
assertEquals(Points.finalPoints(i + 1, 1470, true), classic[i]);
|
||||
}
|
||||
assertEquals(0, classic[735]); // past the 50% cut still 0
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptyBoard() {
|
||||
assertEquals(0, Points.pointedCount(0));
|
||||
|
||||
+24
-2
@@ -5,6 +5,7 @@ import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.Test;
|
||||
import racetimer.TestData;
|
||||
import racetimer.model.BoardEntry;
|
||||
@@ -18,6 +19,27 @@ public class SnapshotBuilderTest {
|
||||
private static final String B = TestData.steam(2);
|
||||
private static final String C = TestData.steam(3);
|
||||
|
||||
@Test
|
||||
public void sameCvarsOnZe1AndZe2ShareACategoryNumber() {
|
||||
// The real ze_pirates_port_royal_v3_6 stage 1 rows.
|
||||
String a = "sv_gravity 800, sv_enablebunnyhopping 1, sv_autobunnyhopping 0, sv_accelerate 5, sv_airaccelerate 10, "
|
||||
+ "tickrate 100, sv_friction 4, client_speed 1.00, client_gravity 1.00";
|
||||
String b = a.replace("sv_enablebunnyhopping 1", "sv_enablebunnyhopping 0");
|
||||
TestData d = new TestData()
|
||||
.category(87, "ze_p", 1, "ze1", TestData.CLASSIC, -706992435L, false)
|
||||
.category(1963, "ze_p", 1, "ze1", a, -1995318401L, false)
|
||||
.category(4412, "ze_p", 1, "ze2", a, -1978540782L, false)
|
||||
.category(4564, "ze_p", 1, "ze1", b, 113025096L, false);
|
||||
Snapshot s = d.build();
|
||||
assertEquals(1, s.categories.get(1963).number);
|
||||
assertEquals(1, s.categories.get(4412).number); // same settings, other server
|
||||
assertEquals(2, s.categories.get(87).number);
|
||||
assertEquals(3, s.categories.get(4564).number);
|
||||
List<Category> shown = s.mapsByName.get("ze_p").stages.get(1);
|
||||
assertEquals("ze1", shown.get(0).serverTag); // Category 1 ZE1, then Category 1 ZE2
|
||||
assertEquals("ze2", shown.get(1).serverTag);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void categoriesAreNumberedByCvarsHashAcrossServers() {
|
||||
TestData d = new TestData()
|
||||
@@ -29,8 +51,8 @@ public class SnapshotBuilderTest {
|
||||
Snapshot s = d.build();
|
||||
assertEquals(1, s.categories.get(12).number); // most negative hash first
|
||||
assertEquals(2, s.categories.get(10).number);
|
||||
assertEquals(3, s.categories.get(14).number); // dev still takes a number, like in-game
|
||||
assertEquals(4, s.categories.get(11).number);
|
||||
assertEquals(3, s.categories.get(11).number);
|
||||
assertEquals(4, s.categories.get(14).number); // hidden dev server does not take a public number
|
||||
assertEquals(1, s.categories.get(13).number);
|
||||
// dev is hidden from the public map list
|
||||
assertEquals(3, s.mapsByName.get("ze_map").stages.get(1).size());
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Base URL of the racetimer backend API (no trailing slash).
|
||||
VITE_API_BASE=https://racebackend.unloze.com/racetimer_endpoints-1.0/api
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env.local
|
||||
@@ -0,0 +1,60 @@
|
||||
# Racetimer website (2026)
|
||||
|
||||
React 18 + TypeScript + Vite frontend for the UNLOZE racetimer. It reads everything from the new Java backend (`racetimer_endpoints`).
|
||||
|
||||
## Pages
|
||||
|
||||
| URL | What it shows |
|
||||
|---|---|
|
||||
| `#/leaderboard` | Players ranked by points, with a ZE1 / ZE2 switch. The choice is remembered in the browser. |
|
||||
| `#/player/<steamid>` | Profile with ZE1 and ZE2 points and ranks. **Records** tab: best time per category with place, bonus multiplier, points and date ("Legacy record" for Classic data). A faster run that was invalidated is listed too, marked **Run invalidated**. **Improvement history** tab: every improvement and how much faster it was, with a chart for one category. |
|
||||
| `#/maps` | Every map with its stages, categories, servers and record counts. |
|
||||
| `#/map/<mapname>?stage=&category=` | Stage tabs and category cards. Categories with exactly the same server settings share a number on ZE1 and ZE2 ("Category 1 ZE1", "Category 1 ZE2"). Shows the category's server settings, highlighting values that differ from other categories of the stage. Leaderboard with gap to #1, bonus, points and the 50% points cut-off line. Invalidated runs stay in the list where their time places them, marked **Invalid**, with no position and 0 points. |
|
||||
| `#/search?q=` | Players and maps matching the header search box. |
|
||||
| `#/points` | Plain-language explanation of the points rules. |
|
||||
|
||||
Old links still work: `#/mapboard` goes to `#/maps`, and `#/map/<name>/<stage>` opens that stage.
|
||||
|
||||
Dates are shown in full, down to the second, in the visitor's own time zone.
|
||||
|
||||
## Admins
|
||||
|
||||
The footer has **Admin sign-in with Steam**. It goes to the backend (`/api/auth/steam/login`). The backend checks SourceBans and sends the admin back with `#token=…`. The site stores the token in the browser and removes it from the address bar.
|
||||
|
||||
While signed in:
|
||||
- a yellow bar shows who is signed in, with **Sign out**
|
||||
- map leaderboards, profile records and profile history show **Invalidate** / **Restore** buttons for single runs
|
||||
- the category panel has **Invalidate** / **Restore** for the whole category
|
||||
|
||||
Every action asks for confirmation first. After a change, all pages reload their data.
|
||||
|
||||
Everybody (signed in or not) sees which admin invalidated a run or category and when, under the Invalid tag.
|
||||
|
||||
Set `frontendUrl` in the backend settings to this site's URL, or the Steam sign-in cannot find its way back.
|
||||
|
||||
## Build and deploy
|
||||
|
||||
Requires Node 18 or newer.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # output in dist/
|
||||
```
|
||||
|
||||
Upload the contents of `dist/` to the web server. The site uses `#` URLs, so no server rewrite rules are needed, and it works from any folder.
|
||||
|
||||
The backend URL defaults to `https://racebackend.unloze.com/racetimer_endpoints-1.0/api`. To use another one, create `.env.local` from `.env.example` before building:
|
||||
|
||||
```
|
||||
VITE_API_BASE=https://your-backend/racetimer_endpoints-1.0/api
|
||||
```
|
||||
|
||||
For development, `npm run dev` serves the site on http://localhost:5173 with live reload.
|
||||
|
||||
## Code layout
|
||||
|
||||
- `src/api/types.ts`: JSON shapes returned by the backend. Keep in sync with `racetimer/dto/Dto.java`.
|
||||
- `src/api/client.ts`: all API calls and the login token.
|
||||
- `src/pages/`: one file per page.
|
||||
- `src/components/`: shared pieces (layout, chips, admin button, chart).
|
||||
- `src/styles.css`: the dark theme. Colors are CSS variables at the top of the file.
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#0d1117" />
|
||||
<meta name="description" content="UNLOZE Zombie Escape racetimer: leaderboards, map records and player profiles." />
|
||||
<link rel="icon" href="./favicon.ico" />
|
||||
<title>UNLOZE Racetimer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1819
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "racetimer_react",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,36 @@
|
||||
import { useState } from "react";
|
||||
import { Navigate, Route, Routes, useParams } from "react-router-dom";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { LeaderboardPage } from "./pages/LeaderboardPage";
|
||||
import { PlayerPage } from "./pages/PlayerPage";
|
||||
import { MapsPage } from "./pages/MapsPage";
|
||||
import { MapPage } from "./pages/MapPage";
|
||||
import { SearchPage } from "./pages/SearchPage";
|
||||
import { PointsPage } from "./pages/PointsPage";
|
||||
import { NotFound } from "./components/States";
|
||||
|
||||
/** Old links looked like #/map/<name>/<stage>. */
|
||||
function OldMapLink() {
|
||||
const { mapname, stage } = useParams();
|
||||
return <Navigate replace to={`/map/${encodeURIComponent(mapname ?? "")}?stage=${stage ?? 1}`} />;
|
||||
}
|
||||
|
||||
export function App({ loginError }: { loginError: string | null }) {
|
||||
const [notice, setNotice] = useState(loginError);
|
||||
return (
|
||||
<Layout notice={notice} onDismissNotice={() => setNotice(null)}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate replace to="/leaderboard" />} />
|
||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||
<Route path="/player/:steamid" element={<PlayerPage />} />
|
||||
<Route path="/maps" element={<MapsPage />} />
|
||||
<Route path="/mapboard" element={<Navigate replace to="/maps" />} />
|
||||
<Route path="/map/:mapname" element={<MapPage />} />
|
||||
<Route path="/map/:mapname/:stage" element={<OldMapLink />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/points" element={<PointsPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type {
|
||||
AuthUser,
|
||||
CategoryBoard,
|
||||
HistoryEntry,
|
||||
MapInfo,
|
||||
Player,
|
||||
PlayerMapRow,
|
||||
ServerTag,
|
||||
} from "./types";
|
||||
|
||||
export const API_BASE: string = (
|
||||
import.meta.env.VITE_API_BASE ?? "https://racebackend.unloze.com/racetimer_endpoints-1.0/api"
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
const TOKEN_KEY = "racetimer.token";
|
||||
|
||||
export function getToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setToken(token: string | null): void {
|
||||
try {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
/* storage blocked: the session only lasts for this page load */
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
const token = getToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
if (init?.body) headers["Content-Type"] = "application/json";
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}/${path}`, { ...init, headers: { ...headers, ...(init?.headers ?? {}) } });
|
||||
} catch {
|
||||
throw new ApiError(0, "Could not reach the racetimer server. Check your connection and try again.");
|
||||
}
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
let message = `Request failed (${res.status})`;
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
if (body?.errorMessage) message = body.errorMessage;
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
return (text ? JSON.parse(text) : null) as T;
|
||||
}
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
export const api = {
|
||||
leaderboard: (server: ServerTag, offset: number) =>
|
||||
request<Player[]>(`timers/leaderboard/${offset}?server=${server}`),
|
||||
player: (steamId: string, server: ServerTag) =>
|
||||
request<Player>(`timers/player/${enc(steamId)}?server=${server}`),
|
||||
playerMaps: (steamId: string, offset: number) =>
|
||||
request<PlayerMapRow[]>(`timers/player/maps/${enc(steamId)}/${offset}`),
|
||||
playerHistory: (steamId: string, offset: number, categoryId?: number) =>
|
||||
request<HistoryEntry[]>(
|
||||
`timers/player/history/${enc(steamId)}/${offset}` + (categoryId ? `?categoryId=${categoryId}` : ""),
|
||||
),
|
||||
allMaps: () => request<MapInfo[]>("timers/allmaps"),
|
||||
map: (mapName: string) => request<MapInfo>(`timers/map/${enc(mapName)}`),
|
||||
category: (id: number, offset: number) => request<CategoryBoard>(`timers/category/${id}/${offset}`),
|
||||
searchPlayers: (q: string, server: ServerTag) =>
|
||||
request<Player[]>(`timers/searchplayers/${enc(q)}?server=${server}`),
|
||||
me: () => request<AuthUser>("auth/me"),
|
||||
setRecordInvalid: (recordId: number, invalid: boolean) =>
|
||||
request<{ id: number; invalid: boolean }>(`admin/records/${recordId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ invalid }),
|
||||
}),
|
||||
setCategoryInvalid: (categoryId: number, invalid: boolean) =>
|
||||
request<{ id: number; invalid: boolean }>(`admin/categories/${categoryId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ invalid }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const steamLoginUrl = `${API_BASE}/auth/steam/login`;
|
||||
@@ -0,0 +1,182 @@
|
||||
// JSON shapes returned by the racetimer backend (racetimer.dto.Dto).
|
||||
// Field names must match the backend exactly.
|
||||
|
||||
export type ServerTag = "ze1" | "ze2";
|
||||
export const SERVERS: ServerTag[] = ["ze1", "ze2"];
|
||||
|
||||
export interface ServerStats {
|
||||
points: number;
|
||||
rank: number;
|
||||
times: number;
|
||||
}
|
||||
|
||||
export interface Badge {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface Player {
|
||||
steamID: string;
|
||||
steamID64: string | null;
|
||||
name: string;
|
||||
Avatar: string;
|
||||
/** Rank on `server`. */
|
||||
Rank: number;
|
||||
/** Points on `server`. */
|
||||
PlayerPoints: number;
|
||||
Times: number;
|
||||
UrlBanners: string[];
|
||||
server: ServerTag;
|
||||
servers: Record<ServerTag, ServerStats>;
|
||||
badges: Badge[];
|
||||
}
|
||||
|
||||
export interface Cvar {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Who marked something invalid and when. Fields are null for rows flagged before this was recorded. */
|
||||
export interface Invalidation {
|
||||
steamID: string | null;
|
||||
steamID64: string | null;
|
||||
name: string | null;
|
||||
/** Unix seconds. */
|
||||
at: number | null;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
/** "Category N", same numbering as in-game. */
|
||||
categoryNumber: number;
|
||||
mapName: string;
|
||||
stage: number;
|
||||
serverTag: ServerTag;
|
||||
serverCvars: string;
|
||||
cvars: Cvar[];
|
||||
/** Migrated CLASSIC RACETIMER data: points divided by 10, dates unknown. */
|
||||
isLegacy: boolean;
|
||||
isInvalid: boolean;
|
||||
invalidation?: Invalidation | null;
|
||||
givesPoints: boolean;
|
||||
completions: number;
|
||||
fastestTime: number | null;
|
||||
}
|
||||
|
||||
export interface Stage {
|
||||
stage: number;
|
||||
allCategoriesInvalid: boolean;
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
export interface MapInfo {
|
||||
mapName: string;
|
||||
allCategoriesInvalid: boolean;
|
||||
stages: Stage[];
|
||||
}
|
||||
|
||||
export interface BoardEntry {
|
||||
recordId: number;
|
||||
/** 0 for an invalidated run. */
|
||||
position: number;
|
||||
steamID: string;
|
||||
steamID64: string | null;
|
||||
name: string;
|
||||
avatar: string;
|
||||
badgesUrls: string[];
|
||||
time: number;
|
||||
points: number;
|
||||
/** 1.0 = no bonus. */
|
||||
bonusMultiplier: number;
|
||||
/** Raised by the small-Classic-board rules (at least 1, top 10 places 1 apart). */
|
||||
pointsRaised?: boolean;
|
||||
/** Unix seconds; null for legacy records. */
|
||||
recordedAt: number | null;
|
||||
isLegacy: boolean;
|
||||
/** Invalidated by an admin: listed in time order, but no position and no points. */
|
||||
isInvalid: boolean;
|
||||
invalidation?: Invalidation | null;
|
||||
}
|
||||
|
||||
export interface InvalidatedEntry {
|
||||
recordId: number;
|
||||
steamID: string;
|
||||
steamID64: string | null;
|
||||
name: string;
|
||||
avatar: string;
|
||||
time: number;
|
||||
recordedAt: number | null;
|
||||
isLegacy: boolean;
|
||||
isInvalid: true;
|
||||
invalidation?: Invalidation | null;
|
||||
}
|
||||
|
||||
export interface CategoryBoard {
|
||||
category: Category;
|
||||
offset: number;
|
||||
pageSize: number;
|
||||
/** Valid entries and invalidated runs together, fastest first. */
|
||||
entries: BoardEntry[];
|
||||
/** The invalidated runs only (they are also in entries). */
|
||||
invalidated: InvalidatedEntry[];
|
||||
}
|
||||
|
||||
export interface PlayerMapRow {
|
||||
recordId: number;
|
||||
categoryId: number;
|
||||
mapName: string;
|
||||
stage: number;
|
||||
categoryNumber: number;
|
||||
serverTag: ServerTag;
|
||||
isLegacy: boolean;
|
||||
categoryInvalid: boolean;
|
||||
/** This run was invalidated by an admin: position 0, no points. */
|
||||
isInvalid: boolean;
|
||||
invalidation?: Invalidation | null;
|
||||
categoryInvalidation?: Invalidation | null;
|
||||
time: number;
|
||||
position: number;
|
||||
completions: number;
|
||||
bonusMultiplier: number;
|
||||
points: number;
|
||||
pointsRaised?: boolean;
|
||||
recordedAt: number | null;
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
recordId: number;
|
||||
categoryId: number;
|
||||
mapName: string;
|
||||
stage: number;
|
||||
categoryNumber: number;
|
||||
serverTag: ServerTag;
|
||||
isLegacy: boolean;
|
||||
categoryInvalid: boolean;
|
||||
time: number;
|
||||
previousTime: number | null;
|
||||
improvedBy: number | null;
|
||||
recordedAt: number | null;
|
||||
isInvalid: boolean;
|
||||
invalidation?: Invalidation | null;
|
||||
categoryInvalidation?: Invalidation | null;
|
||||
isCurrentBest: boolean;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
steamID: string;
|
||||
steamID64: string | null;
|
||||
name: string;
|
||||
role: "admin" | "staff";
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface ApiErrorBody {
|
||||
statusCode: number;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
// Page sizes used by the backend.
|
||||
export const LEADERBOARD_PAGE = 100;
|
||||
export const CATEGORY_PAGE = 75;
|
||||
export const PLAYER_MAPS_PAGE = 50;
|
||||
export const HISTORY_PAGE = 50;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { api, ApiError, getToken, setToken } from "./api/client";
|
||||
import type { AuthUser } from "./api/types";
|
||||
|
||||
interface AuthState {
|
||||
user: AuthUser | null;
|
||||
signOut: () => void;
|
||||
/** Called when an admin request comes back 401 (session expired). */
|
||||
expired: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState>({ user: null, signOut: () => {}, expired: () => {} });
|
||||
|
||||
const LOGIN_ERRORS: Record<string, string> = {
|
||||
not_admin: "That Steam account is not a SourceBans admin, so it cannot sign in here.",
|
||||
steam_verification_failed: "Steam sign-in could not be verified. Please try again.",
|
||||
admin_check_unavailable: "The admin check is unavailable right now. Please try again later.",
|
||||
};
|
||||
|
||||
/**
|
||||
* After Steam sign-in the backend sends the browser to "<site>/#token=..."
|
||||
* or "<site>/#loginError=...". Pick that up before the router sees the hash,
|
||||
* then clean the address bar.
|
||||
*/
|
||||
export function captureLoginRedirect(): string | null {
|
||||
const hash = window.location.hash;
|
||||
const clean = () => window.history.replaceState(null, "", window.location.pathname + window.location.search + "#/");
|
||||
if (hash.startsWith("#token=")) {
|
||||
setToken(decodeURIComponent(hash.slice("#token=".length)));
|
||||
clean();
|
||||
return null;
|
||||
}
|
||||
if (hash.startsWith("#loginError=")) {
|
||||
const code = decodeURIComponent(hash.slice("#loginError=".length));
|
||||
clean();
|
||||
return LOGIN_ERRORS[code] ?? "Sign-in failed.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) return;
|
||||
let cancelled = false;
|
||||
api
|
||||
.me()
|
||||
.then((u) => !cancelled && setUser(u))
|
||||
.catch((e) => {
|
||||
if (e instanceof ApiError && e.status === 401) setToken(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ user, signOut, expired: signOut }), [user, signOut]);
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, ApiError } from "../api/client";
|
||||
import { useAuth } from "../auth";
|
||||
|
||||
/**
|
||||
* Admin-only button that marks a record or a whole category invalid (or
|
||||
* restores it). Asks for confirmation first, then refreshes every cached
|
||||
* view, because points and positions change everywhere.
|
||||
*/
|
||||
export function AdminToggle({
|
||||
kind,
|
||||
id,
|
||||
invalid,
|
||||
what,
|
||||
}: {
|
||||
kind: "record" | "category";
|
||||
id: number;
|
||||
/** Current state. */
|
||||
invalid: boolean;
|
||||
/** Shown in the confirmation, e.g. "jenz's 12.345 on Category 2". */
|
||||
what: string;
|
||||
}) {
|
||||
const { user, expired } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const m = useMutation({
|
||||
mutationFn: () => (kind === "record" ? api.setRecordInvalid(id, !invalid) : api.setCategoryInvalid(id, !invalid)),
|
||||
onSuccess: () => {
|
||||
setConfirming(false);
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
onError: (e) => {
|
||||
if (e instanceof ApiError && e.status === 401) expired();
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const action = invalid ? "Restore" : "Invalidate";
|
||||
if (!confirming) {
|
||||
return (
|
||||
<button
|
||||
className={invalid ? "btn btn-sm btn-ghost" : "btn btn-sm btn-danger-ghost"}
|
||||
onClick={() => setConfirming(true)}
|
||||
title={invalid ? `Make this ${kind} count again` : `Mark this ${kind} as invalid (no points)`}
|
||||
>
|
||||
{action}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="confirm" role="group" aria-label={`${action} ${what}?`}>
|
||||
<span className="confirm-text">
|
||||
{action} {what}?
|
||||
</span>
|
||||
<button className={invalid ? "btn btn-sm" : "btn btn-sm btn-danger"} onClick={() => m.mutate()} disabled={m.isPending}>
|
||||
{m.isPending ? "Saving…" : `Yes, ${action.toLowerCase()}`}
|
||||
</button>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => setConfirming(false)} disabled={m.isPending}>
|
||||
Cancel
|
||||
</button>
|
||||
{m.isError && <span className="error-text">{(m.error as Error).message}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { SERVERS, type Invalidation, type ServerTag } from "../api/types";
|
||||
import { bonusLabel, formatDate, formatNumber, serverLabel, timeAgo } from "../format";
|
||||
|
||||
/** Hide an image that failed to load instead of showing the browser's broken-image icon. */
|
||||
export function hideBroken(e: React.SyntheticEvent<HTMLImageElement>) {
|
||||
e.currentTarget.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
export function PlayerCell({
|
||||
steamID,
|
||||
name,
|
||||
avatar,
|
||||
badges = [],
|
||||
}: {
|
||||
steamID: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
badges?: string[];
|
||||
}) {
|
||||
return (
|
||||
<Link to={`/player/${encodeURIComponent(steamID)}`} className="player-cell">
|
||||
{avatar && <img src={avatar} alt="" className="avatar" loading="lazy" width={28} height={28} onError={hideBroken} />}
|
||||
<span className="player-name">{name}</span>
|
||||
{badges.length > 0 && (
|
||||
<span className="badges" aria-hidden="true">
|
||||
{badges.slice(0, 3).map((b) => (
|
||||
<img key={b} src={b} alt="" loading="lazy" onError={hideBroken} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** #1-#3 get medal colors; the number is always shown so color is never the only signal. */
|
||||
export function Rank({ value }: { value: number }) {
|
||||
const cls = value === 1 ? "rank gold" : value === 2 ? "rank silver" : value === 3 ? "rank bronze" : "rank";
|
||||
return <span className={cls}>#{value}</span>;
|
||||
}
|
||||
|
||||
export function ServerSwitch({ value, onChange }: { value: ServerTag; onChange: (s: ServerTag) => void }) {
|
||||
return (
|
||||
<div className="segmented" role="group" aria-label="Server">
|
||||
{SERVERS.map((s) => (
|
||||
<button key={s} aria-pressed={value === s} className={value === s ? "on" : ""} onClick={() => onChange(s)}>
|
||||
{serverLabel(s)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServerTagChip({ tag }: { tag: string }) {
|
||||
return <span className={`chip chip-server chip-${tag}`}>{serverLabel(tag)}</span>;
|
||||
}
|
||||
|
||||
export function LegacyChip() {
|
||||
return (
|
||||
<span className="chip chip-legacy" title="Migrated from the old racetimer (CLASSIC RACETIMER). Points are divided by 10.">
|
||||
Classic
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvalidChip({ label = "Invalid" }: { label?: string }) {
|
||||
return (
|
||||
<span className="chip chip-invalid" title="Marked invalid by an admin. It does not give points.">
|
||||
⚠ {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Invalidated by jenz · 30 Sep 2026, 15:20". Shown to everybody.
|
||||
* Older rows flagged before the admin was recorded say so instead.
|
||||
*/
|
||||
export function InvalidatedBy({
|
||||
info,
|
||||
what = "Invalidated",
|
||||
className = "",
|
||||
}: {
|
||||
info: Invalidation | null | undefined;
|
||||
what?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const name = info?.name ?? info?.steamID ?? null;
|
||||
const who = name ? (
|
||||
info?.steamID64 ? (
|
||||
<a href={`https://steamcommunity.com/profiles/${info.steamID64}`} target="_blank" rel="noreferrer" title={info.steamID ?? undefined}>
|
||||
{name}
|
||||
</a>
|
||||
) : (
|
||||
<span title={info?.steamID ?? undefined}>{name}</span>
|
||||
)
|
||||
) : (
|
||||
<span>an admin</span>
|
||||
);
|
||||
return (
|
||||
<span className={`invalidated-by ${className}`}>
|
||||
{what} by {who}
|
||||
{info?.at != null ? (
|
||||
<>
|
||||
{" · "}
|
||||
<time dateTime={new Date(info.at * 1000).toISOString()}>{formatDate(info.at)}</time>
|
||||
</>
|
||||
) : (
|
||||
!name && <span className="muted"> (before this was recorded)</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const RAISED_TITLE =
|
||||
"Raised so small Classic boards stay fair: every time that earns points gets at least 1, and the top 10 places are at least 1 point apart.";
|
||||
|
||||
/** Points, with a small ↑ when the small-Classic-board rules raised them. */
|
||||
export function PointsValue({ points, raised }: { points: number; raised?: boolean }) {
|
||||
if (!raised) return <>{formatNumber(points)}</>;
|
||||
return (
|
||||
<span title={RAISED_TITLE}>
|
||||
{formatNumber(points)}
|
||||
<span className="raised-mark" aria-label="raised"> ↑</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function BonusChip({
|
||||
multiplier,
|
||||
position,
|
||||
completions,
|
||||
}: {
|
||||
multiplier: number;
|
||||
position: number;
|
||||
completions: number;
|
||||
}) {
|
||||
if (multiplier <= 1.0001) return <span className="muted">–</span>;
|
||||
const band = bonusLabel(position, completions);
|
||||
return (
|
||||
<span className="chip chip-bonus" title={`Points are multiplied by ${multiplier.toFixed(2)} for this position`}>
|
||||
×{Number(multiplier.toFixed(2))}
|
||||
{band && <span className="chip-bonus-band"> {band}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecordedAt({ at, legacy }: { at: number | null; legacy: boolean }) {
|
||||
if (legacy || at === null) {
|
||||
return (
|
||||
<span className="muted" title="Set on the old racetimer; the exact date is unknown.">
|
||||
Legacy record
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<time className="date" dateTime={new Date(at * 1000).toISOString()} title={timeAgo(at)}>
|
||||
{formatDate(at)}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
export function CategoryName({ number, legacy }: { number: number; legacy: boolean }) {
|
||||
return (
|
||||
<span className="cat-name">
|
||||
Category {number}
|
||||
{legacy && <LegacyChip />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadMore({
|
||||
hasMore,
|
||||
loading,
|
||||
onClick,
|
||||
}: {
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
if (!hasMore) return null;
|
||||
return (
|
||||
<div className="loadmore">
|
||||
<button className="btn" onClick={onClick} disabled={loading}>
|
||||
{loading ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import { Link, NavLink, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../auth";
|
||||
import { steamLoginUrl } from "../api/client";
|
||||
|
||||
export function Layout({
|
||||
children,
|
||||
notice,
|
||||
onDismissNotice,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
notice: string | null;
|
||||
onDismissNotice: () => void;
|
||||
}) {
|
||||
const { user, signOut } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const text = q.trim();
|
||||
if (text) navigate(`/search?q=${encodeURIComponent(text)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<div className="topbar-inner">
|
||||
<Link to="/leaderboard" className="brand" aria-label="UNLOZE Racetimer home">
|
||||
<span className="brand-mark" aria-hidden="true">⏱</span>
|
||||
<span className="brand-name">
|
||||
UNLOZE <span className="brand-sub">Racetimer</span>
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="mainnav" aria-label="Main">
|
||||
<NavLink to="/leaderboard">Leaderboard</NavLink>
|
||||
<NavLink to="/maps">Maps</NavLink>
|
||||
<NavLink to="/points">Points</NavLink>
|
||||
</nav>
|
||||
<form className="search" role="search" onSubmit={submit}>
|
||||
<input
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search players or maps"
|
||||
aria-label="Search players or maps"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{user && (
|
||||
<div className="adminbar" role="status">
|
||||
<div className="container adminbar-inner">
|
||||
<span>
|
||||
Signed in as <strong>{user.name}</strong> ({user.role}). Admin controls are shown on map boards and
|
||||
profiles.
|
||||
</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={signOut}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<div className="container">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
<span>{notice}</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={onDismissNotice}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="container main">{children}</main>
|
||||
|
||||
<footer className="footer">
|
||||
<div className="container footer-inner">
|
||||
<span>
|
||||
UNLOZE Zombie Escape racetimer · <a href="https://unloze.com">unloze.com</a>
|
||||
</span>
|
||||
{!user && (
|
||||
<a className="footer-admin" href={steamLoginUrl}>
|
||||
Admin sign-in with Steam
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { ApiError } from "../api/client";
|
||||
|
||||
export function Loading({ label = "Loading…" }: { label?: string }) {
|
||||
return (
|
||||
<div className="state" role="status" aria-live="polite">
|
||||
<span className="spinner" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBox({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
|
||||
const message = error instanceof Error ? error.message : "Something went wrong.";
|
||||
return (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
<span>{message}</span>
|
||||
{onRetry && (
|
||||
<button className="btn btn-ghost btn-sm" onClick={onRetry}>
|
||||
Try again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function isNotFound(error: unknown): boolean {
|
||||
return error instanceof ApiError && error.status === 404;
|
||||
}
|
||||
|
||||
export function Empty({ children }: { children: React.ReactNode }) {
|
||||
return <div className="state empty">{children}</div>;
|
||||
}
|
||||
|
||||
export function NotFound({ what = "page" }: { what?: string }) {
|
||||
return (
|
||||
<div className="state empty">
|
||||
<h1>Not found</h1>
|
||||
<p>This {what} does not exist.</p>
|
||||
<Link to="/leaderboard" className="btn">
|
||||
Back to the leaderboard
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { HistoryEntry } from "../api/types";
|
||||
import { formatDate, formatTime } from "../format";
|
||||
|
||||
const H = 220;
|
||||
const PAD = { top: 16, right: 20, bottom: 30, left: 64 };
|
||||
|
||||
function niceTicks(min: number, max: number, count = 4): number[] {
|
||||
const span = max - min || 1;
|
||||
const raw = span / count;
|
||||
const pow = Math.pow(10, Math.floor(Math.log10(raw)));
|
||||
const step = [1, 2, 2.5, 5, 10].map((m) => m * pow).find((s) => s >= raw) ?? raw;
|
||||
const start = Math.ceil(min / step) * step;
|
||||
const out: number[] = [];
|
||||
for (let v = start; v <= max + 1e-9; v += step) out.push(Math.round(v * 1000) / 1000);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A player's best time in one category over time (lower is better), drawn
|
||||
* as a step line: the best time stays flat until the next improvement.
|
||||
* Invalidated runs and legacy records (no date) are left out.
|
||||
*/
|
||||
export function TimeChart({ entries }: { entries: HistoryEntry[] }) {
|
||||
const points = useMemo(
|
||||
() =>
|
||||
entries
|
||||
.filter((e) => !e.isInvalid && e.recordedAt !== null)
|
||||
.map((e) => ({ t: e.recordedAt as number, v: e.time, e }))
|
||||
.sort((a, b) => a.t - b.t),
|
||||
[entries],
|
||||
);
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
// Draw at the real pixel width so text and lines keep their size on any screen.
|
||||
const [W, setW] = useState(640);
|
||||
useEffect(() => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => setW(Math.max(280, Math.round(el.clientWidth))));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
if (points.length < 2) return null;
|
||||
|
||||
const t0 = points[0].t;
|
||||
const t1 = Math.max(points[points.length - 1].t, t0 + 1);
|
||||
const vMin = Math.min(...points.map((p) => p.v));
|
||||
const vMax = Math.max(...points.map((p) => p.v));
|
||||
const pad = (vMax - vMin) * 0.12 || 1;
|
||||
const yMin = vMin - pad;
|
||||
const yMax = vMax + pad;
|
||||
const x = (t: number) => PAD.left + ((t - t0) / (t1 - t0)) * (W - PAD.left - PAD.right);
|
||||
const y = (v: number) => PAD.top + ((v - yMin) / (yMax - yMin)) * (H - PAD.top - PAD.bottom);
|
||||
|
||||
let d = `M${x(points[0].t)},${y(points[0].v)}`;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
d += ` H${x(points[i].t)} V${y(points[i].v)}`;
|
||||
}
|
||||
const ticks = niceTicks(yMin, yMax);
|
||||
const dateTicks = [t0, (t0 + t1) / 2, t1];
|
||||
|
||||
const onMove = (ev: React.MouseEvent<SVGSVGElement>) => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
const r = svg.getBoundingClientRect();
|
||||
const px = ((ev.clientX - r.left) / r.width) * W;
|
||||
let best = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (Math.abs(x(points[i].t) - px) < Math.abs(x(points[best].t) - px)) best = i;
|
||||
}
|
||||
setHover(best);
|
||||
};
|
||||
|
||||
const h = hover === null ? null : points[hover];
|
||||
const tipX = h ? x(h.t) : 0;
|
||||
// Keep the tooltip inside the chart near the edges.
|
||||
const tipShift = tipX < 90 ? "0%" : tipX > W - 90 ? "-100%" : "-50%";
|
||||
|
||||
return (
|
||||
<figure className="chart">
|
||||
<figcaption className="chart-title">Best time over time (faster times are higher)</figcaption>
|
||||
<div className="chart-wrap" ref={wrapRef}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
width={W}
|
||||
height={H}
|
||||
role="img"
|
||||
aria-label={`Best time went from ${formatTime(points[0].v)} to ${formatTime(points[points.length - 1].v)}`}
|
||||
onMouseMove={onMove}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
{ticks.map((v) => (
|
||||
<g key={v}>
|
||||
<line x1={PAD.left} x2={W - PAD.right} y1={y(v)} y2={y(v)} className="chart-grid" />
|
||||
<text x={PAD.left - 8} y={y(v)} className="chart-axis" textAnchor="end" dominantBaseline="middle">
|
||||
{formatTime(v)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{dateTicks.map((t, i) => (
|
||||
<text
|
||||
key={t}
|
||||
x={x(t)}
|
||||
y={H - 8}
|
||||
className="chart-axis"
|
||||
textAnchor={i === 0 ? "start" : i === 2 ? "end" : "middle"}
|
||||
>
|
||||
{new Date(t * 1000).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}
|
||||
</text>
|
||||
))}
|
||||
<path d={d} className="chart-line" />
|
||||
{h && <line x1={x(h.t)} x2={x(h.t)} y1={PAD.top} y2={H - PAD.bottom} className="chart-crosshair" />}
|
||||
{points.map((p, i) => (
|
||||
<circle key={p.e.recordId} cx={x(p.t)} cy={y(p.v)} r={hover === i ? 6 : 4} className="chart-dot" />
|
||||
))}
|
||||
{/* Direct label on the latest point only. */}
|
||||
<text
|
||||
x={x(points[points.length - 1].t) - 8}
|
||||
y={y(points[points.length - 1].v) - 10}
|
||||
className="chart-label"
|
||||
textAnchor="end"
|
||||
>
|
||||
{formatTime(points[points.length - 1].v)}
|
||||
</text>
|
||||
</svg>
|
||||
{h && (
|
||||
<div
|
||||
className="chart-tip"
|
||||
style={{ left: tipX, top: y(h.v), transform: `translate(${tipShift}, calc(-100% - 12px))` }}
|
||||
role="status"
|
||||
>
|
||||
<strong>{formatTime(h.v)}</strong>
|
||||
{h.e.improvedBy !== null && <span className="good"> −{formatTime(h.e.improvedBy)}</span>}
|
||||
<br />
|
||||
<span className="muted">{formatDate(h.t)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
const number = new Intl.NumberFormat("en-US");
|
||||
|
||||
/** 12.345 -> "12.345", 83.2 -> "1:23.200", 3723.5 -> "1:02:03.500". */
|
||||
export function formatTime(seconds: number): string {
|
||||
const ms = Math.round(seconds * 1000);
|
||||
const h = Math.floor(ms / 3_600_000);
|
||||
const m = Math.floor((ms % 3_600_000) / 60_000);
|
||||
const s = Math.floor((ms % 60_000) / 1000);
|
||||
const rest = String(ms % 1000).padStart(3, "0");
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${rest}`;
|
||||
if (m > 0) return `${m}:${String(s).padStart(2, "0")}.${rest}`;
|
||||
return `${s}.${rest}`;
|
||||
}
|
||||
|
||||
/** Signed difference, e.g. "+1.204" or "−0.500". */
|
||||
export function formatDelta(seconds: number): string {
|
||||
const sign = seconds < 0 ? "−" : "+";
|
||||
return sign + formatTime(Math.abs(seconds));
|
||||
}
|
||||
|
||||
export function formatNumber(n: number): string {
|
||||
return number.format(n);
|
||||
}
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||
["year", 31_536_000],
|
||||
["month", 2_592_000],
|
||||
["week", 604_800],
|
||||
["day", 86_400],
|
||||
["hour", 3_600],
|
||||
["minute", 60],
|
||||
];
|
||||
|
||||
/** Unix seconds -> "3 weeks ago". */
|
||||
export function timeAgo(unixSeconds: number, now = Date.now() / 1000): string {
|
||||
// Never show "in 3 minutes" when a server clock is slightly ahead.
|
||||
const diff = Math.min(0, unixSeconds - now);
|
||||
for (const [unit, size] of UNITS) {
|
||||
if (Math.abs(diff) >= size) return rtf.format(Math.round(diff / size), unit);
|
||||
}
|
||||
return "just now";
|
||||
}
|
||||
|
||||
/** Unix seconds -> "27 Sep 2026, 14:03:09" in the viewer's own time zone. */
|
||||
export function formatDate(unixSeconds: number): string {
|
||||
return new Date(unixSeconds * 1000).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function serverLabel(tag: string): string {
|
||||
return tag.toUpperCase();
|
||||
}
|
||||
|
||||
/** Which bonus band a position is in, for the label next to the multiplier. */
|
||||
export function bonusLabel(position: number, completions: number): string | null {
|
||||
if (completions < 100) return null;
|
||||
const x = (position - 1) / completions;
|
||||
if (x < 0.01) return "Top 1%";
|
||||
if (x < 0.05) return "Top 5%";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** How many players on a board get points (half from 200 completions on). */
|
||||
export function pointedCount(completions: number): number {
|
||||
return completions < 200 ? completions : Math.floor(completions / 2);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
import { App } from "./App";
|
||||
import { AuthProvider, captureLoginRedirect } from "./auth";
|
||||
import { ServerProvider } from "./server";
|
||||
import { ApiError } from "./api/client";
|
||||
import "./styles.css";
|
||||
|
||||
const loginError = captureLoginRedirect();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// The backend recalculates every 30 minutes, so a few minutes of caching is plenty.
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: (count, error) => !(error instanceof ApiError && error.status >= 400 && error.status < 500) && count < 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<ServerProvider>
|
||||
{/* Hash URLs (#/player/...) keep the old site's links working. */}
|
||||
<HashRouter>
|
||||
<App loginError={loginError} />
|
||||
</HashRouter>
|
||||
</ServerProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import { LEADERBOARD_PAGE } from "../api/types";
|
||||
import { LoadMore, PlayerCell, Rank, ServerSwitch } from "../components/Bits";
|
||||
import { Empty, ErrorBox, Loading } from "../components/States";
|
||||
import { formatNumber, serverLabel } from "../format";
|
||||
import { useServer } from "../server";
|
||||
|
||||
export function LeaderboardPage() {
|
||||
const { server, setServer } = useServer();
|
||||
const q = useInfiniteQuery({
|
||||
queryKey: ["leaderboard", server],
|
||||
queryFn: ({ pageParam }) => api.leaderboard(server, pageParam),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (last, all) => (last.length < LEADERBOARD_PAGE ? undefined : all.length * LEADERBOARD_PAGE),
|
||||
});
|
||||
const players = q.data?.pages.flat() ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-head">
|
||||
<div>
|
||||
<h1>{serverLabel(server)} leaderboard</h1>
|
||||
<p className="lead">
|
||||
Players ranked by racetimer points on {serverLabel(server)}. ZE1 and ZE2 are ranked separately.{" "}
|
||||
<Link to="/points">How points work</Link>
|
||||
</p>
|
||||
</div>
|
||||
<ServerSwitch value={server} onChange={setServer} />
|
||||
</section>
|
||||
|
||||
{q.isPending && <Loading />}
|
||||
{q.isError && <ErrorBox error={q.error} onRetry={() => q.refetch()} />}
|
||||
{q.isSuccess && players.length === 0 && <Empty>No one has a time on {serverLabel(server)} yet.</Empty>}
|
||||
|
||||
{players.length > 0 && (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="num">Rank</th>
|
||||
<th>Player</th>
|
||||
<th className="num">Points</th>
|
||||
<th className="num hide-sm">Records</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{players.map((p) => (
|
||||
<tr key={p.steamID}>
|
||||
<td className="num">
|
||||
<Rank value={p.Rank} />
|
||||
</td>
|
||||
<td>
|
||||
<PlayerCell steamID={p.steamID} name={p.name} avatar={p.Avatar} badges={p.UrlBanners} />
|
||||
</td>
|
||||
<td className="num strong">{formatNumber(p.PlayerPoints)}</td>
|
||||
<td className="num hide-sm">{formatNumber(p.Times)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<LoadMore hasMore={!!q.hasNextPage} loading={q.isFetchingNextPage} onClick={() => q.fetchNextPage()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { Fragment } from "react";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import { CATEGORY_PAGE, type Category, type CategoryBoard, type Stage } from "../api/types";
|
||||
import {
|
||||
BonusChip,
|
||||
InvalidatedBy,
|
||||
InvalidChip,
|
||||
LegacyChip,
|
||||
LoadMore,
|
||||
PlayerCell,
|
||||
PointsValue,
|
||||
Rank,
|
||||
RecordedAt,
|
||||
ServerTagChip,
|
||||
} from "../components/Bits";
|
||||
import { AdminToggle } from "../components/AdminToggle";
|
||||
import { Empty, ErrorBox, isNotFound, Loading, NotFound } from "../components/States";
|
||||
import { formatDelta, formatNumber, formatTime, pointedCount, serverLabel } from "../format";
|
||||
|
||||
export function MapPage() {
|
||||
const { mapname = "" } = useParams();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const map = useQuery({ queryKey: ["map", mapname.toLowerCase()], queryFn: () => api.map(mapname) });
|
||||
|
||||
if (map.isPending) return <Loading />;
|
||||
if (map.isError) {
|
||||
return isNotFound(map.error) ? <NotFound what="map" /> : <ErrorBox error={map.error} onRetry={() => map.refetch()} />;
|
||||
}
|
||||
const m = map.data;
|
||||
const stageNo = Number(params.get("stage")) || m.stages[0]?.stage;
|
||||
const stage = m.stages.find((s) => s.stage === stageNo) ?? m.stages[0];
|
||||
if (!stage) return <Empty>This map has no categories yet.</Empty>;
|
||||
const catId = Number(params.get("category"));
|
||||
const category =
|
||||
stage.categories.find((c) => c.id === catId) ??
|
||||
stage.categories.find((c) => !c.isInvalid) ??
|
||||
stage.categories[0];
|
||||
|
||||
const select = (s: number, c?: number) => {
|
||||
const next = new URLSearchParams();
|
||||
next.set("stage", String(s));
|
||||
if (c) next.set("category", String(c));
|
||||
setParams(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-head">
|
||||
<div>
|
||||
<h1 className="map-title">{m.mapName}</h1>
|
||||
<p className="lead">
|
||||
{m.stages.length === 1 ? "1 stage" : `${m.stages.length} stages`}
|
||||
{m.allCategoriesInvalid && (
|
||||
<>
|
||||
{" "}
|
||||
· <InvalidChip label="Every category on this map is invalid" />
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{m.stages.length > 1 && (
|
||||
<div className="tabs" role="tablist" aria-label="Stages">
|
||||
{m.stages.map((s) => (
|
||||
<button
|
||||
key={s.stage}
|
||||
role="tab"
|
||||
aria-selected={s.stage === stage.stage}
|
||||
className={s.stage === stage.stage ? "on" : ""}
|
||||
onClick={() => select(s.stage)}
|
||||
>
|
||||
Stage {s.stage}
|
||||
{s.allCategoriesInvalid && <span className="tab-flag" title="Every category of this stage is invalid"> ⚠</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stage.allCategoriesInvalid && (
|
||||
<div className="alert alert-warn">
|
||||
Every category of stage {stage.stage} has been marked invalid by an admin, so this stage gives no points.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CategoryPicker stage={stage} selected={category} onSelect={(c) => select(stage.stage, c)} />
|
||||
<CategoryDetails stage={stage} category={category} />
|
||||
<Board category={category} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryPicker({
|
||||
stage,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
stage: Stage;
|
||||
selected: Category;
|
||||
onSelect: (id: number) => void;
|
||||
}) {
|
||||
if (stage.categories.length <= 1) return null;
|
||||
return (
|
||||
<div className="cat-picker" role="tablist" aria-label="Categories">
|
||||
{stage.categories.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
role="tab"
|
||||
aria-selected={c.id === selected.id}
|
||||
className={`cat-card ${c.id === selected.id ? "on" : ""} ${c.isInvalid ? "is-invalid" : ""}`}
|
||||
onClick={() => onSelect(c.id)}
|
||||
>
|
||||
<span className="cat-card-title">
|
||||
Category {c.categoryNumber} <ServerTagChip tag={c.serverTag} />
|
||||
</span>
|
||||
<span className="cat-card-sub">
|
||||
{formatNumber(c.completions)} records
|
||||
{c.fastestTime !== null && <> · best {formatTime(c.fastestTime)}</>}
|
||||
</span>
|
||||
<span className="cell-chips">
|
||||
{c.isLegacy && <LegacyChip />}
|
||||
{c.isInvalid && <InvalidChip />}
|
||||
</span>
|
||||
{c.isInvalid && <InvalidatedBy info={c.invalidation} className="small" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Server settings of the selected category; values that differ between this stage's categories are highlighted. */
|
||||
function CategoryDetails({ stage, category }: { stage: Stage; category: Category }) {
|
||||
const others = stage.categories.filter((c) => c.id !== category.id && !c.isLegacy);
|
||||
const differs = (name: string, value: string) =>
|
||||
others.some((o) => {
|
||||
const v = o.cvars.find((x) => x.name === name);
|
||||
return v !== undefined && v.value !== value;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<h2>
|
||||
Category {category.categoryNumber} <ServerTagChip tag={category.serverTag} />
|
||||
{category.isLegacy && <LegacyChip />}
|
||||
{category.isInvalid && <InvalidChip />}
|
||||
</h2>
|
||||
<AdminToggle
|
||||
kind="category"
|
||||
id={category.id}
|
||||
invalid={category.isInvalid}
|
||||
what={`category ${category.categoryNumber} of stage ${category.stage}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{category.isInvalid && (
|
||||
<div className="alert alert-warn">
|
||||
<span>
|
||||
<strong>
|
||||
<InvalidatedBy info={category.invalidation} what="Category marked invalid" />
|
||||
</strong>
|
||||
. Its times are still listed, but none of them give points.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{category.isLegacy ? (
|
||||
<p className="muted">
|
||||
Times from the old racetimer (CLASSIC RACETIMER). The server settings were not recorded back then, the exact dates are
|
||||
unknown, and points from this category are divided by 10.
|
||||
</p>
|
||||
) : category.cvars.length > 0 ? (
|
||||
<>
|
||||
<p className="hint">
|
||||
Server settings when these times were set.
|
||||
{others.length > 0 && " Highlighted values differ from another category of this stage."}
|
||||
</p>
|
||||
<dl className="cvars">
|
||||
{category.cvars.map((c) => {
|
||||
const diff = differs(c.name, c.value);
|
||||
return (
|
||||
<div key={c.name} className={diff ? "cvar diff" : "cvar"}>
|
||||
<dt>{c.name}</dt>
|
||||
<dd>
|
||||
{c.value}
|
||||
{diff && <span className="sr-only"> (differs)</span>}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</>
|
||||
) : (
|
||||
<p className="muted mono">{category.serverCvars}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Board({ category }: { category: Category }) {
|
||||
const q = useInfiniteQuery({
|
||||
queryKey: ["category", category.id],
|
||||
queryFn: ({ pageParam }) => api.category(category.id, pageParam),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (last: CategoryBoard, all) =>
|
||||
last.entries.length < CATEGORY_PAGE ? undefined : all.length * CATEGORY_PAGE,
|
||||
});
|
||||
const pages = q.data?.pages ?? [];
|
||||
const entries = pages.flatMap((p) => p.entries);
|
||||
const hasInvalid = entries.some((e) => e.isInvalid);
|
||||
const hasRaised = entries.some((e) => e.pointsRaised);
|
||||
const fresh = pages[0]?.category ?? category;
|
||||
const cut = fresh.completions >= 200 ? pointedCount(fresh.completions) : null;
|
||||
const best = entries.find((e) => !e.isInvalid)?.time;
|
||||
|
||||
if (q.isPending) return <Loading />;
|
||||
if (q.isError) return <ErrorBox error={q.error} onRetry={() => q.refetch()} />;
|
||||
|
||||
// Position of the previous valid row, for drawing the points cut-off line.
|
||||
let lastValidPosition = 0;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="board-head">
|
||||
<h2>Leaderboard</h2>
|
||||
<span className="muted">
|
||||
{formatNumber(fresh.completions)} {fresh.completions === 1 ? "record" : "records"}
|
||||
{cut !== null && <> · top {formatNumber(cut)} earn points</>}
|
||||
{fresh.completions >= 100 && <> · top 5% get a bonus</>}
|
||||
</span>
|
||||
</div>
|
||||
{hasInvalid && (
|
||||
<p className="hint">
|
||||
Rows marked <InvalidChip /> were invalidated by an admin (shown under the name, with the date). They are listed where
|
||||
their time would place them, but they have no position and give no points; the player's previous valid time counts
|
||||
instead.
|
||||
</p>
|
||||
)}
|
||||
{hasRaised && (
|
||||
<p className="hint">
|
||||
<span className="raised-mark">↑</span> Classic points are divided by 10, which on a small board would give almost
|
||||
everyone the same points. So every time that earns points gets at least 1, and each of the top 10 places gets at least 1
|
||||
point more than the place below it. <Link to="/points">How points work</Link>
|
||||
</p>
|
||||
)}
|
||||
{entries.length === 0 ? (
|
||||
<Empty>No times in this category yet.</Empty>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="num">#</th>
|
||||
<th>Player</th>
|
||||
<th className="num">Time</th>
|
||||
<th className="num hide-sm">Gap</th>
|
||||
<th className="hide-sm">Bonus</th>
|
||||
<th className="num">Points</th>
|
||||
<th className="hide-sm">Set</th>
|
||||
<th className="hide-sm" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => {
|
||||
const showCut =
|
||||
!e.isInvalid && cut !== null && lastValidPosition > 0 && lastValidPosition <= cut && e.position > cut;
|
||||
if (!e.isInvalid) lastValidPosition = e.position;
|
||||
const what = `${e.name}'s ${formatTime(e.time)}`;
|
||||
return (
|
||||
<Fragment key={e.recordId}>
|
||||
{showCut && (
|
||||
<tr className="cut-row">
|
||||
<td colSpan={8}>Points cut-off: only the faster half of this board earns points</td>
|
||||
</tr>
|
||||
)}
|
||||
{e.isInvalid ? (
|
||||
<tr className="row-invalid run-invalid">
|
||||
<td className="num muted" title="Invalidated runs have no position">
|
||||
–
|
||||
</td>
|
||||
<td>
|
||||
<div className="cell-with-chip">
|
||||
<PlayerCell steamID={e.steamID} name={e.name} avatar={e.avatar} badges={e.badgesUrls} />
|
||||
<InvalidChip />
|
||||
</div>
|
||||
<InvalidatedBy info={e.invalidation} className="small" />
|
||||
</td>
|
||||
<td className="num mono strike">{formatTime(e.time)}</td>
|
||||
<td className="num mono muted hide-sm" />
|
||||
<td className="hide-sm" />
|
||||
<td className="num muted">0</td>
|
||||
<td className="hide-sm">
|
||||
<RecordedAt at={e.recordedAt} legacy={e.isLegacy} />
|
||||
</td>
|
||||
<td className="hide-sm">
|
||||
<AdminToggle kind="record" id={e.recordId} invalid what={what} />
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr>
|
||||
<td className="num">
|
||||
<Rank value={e.position} />
|
||||
</td>
|
||||
<td>
|
||||
<PlayerCell steamID={e.steamID} name={e.name} avatar={e.avatar} badges={e.badgesUrls} />
|
||||
</td>
|
||||
<td className="num mono strong">{formatTime(e.time)}</td>
|
||||
<td className="num mono muted hide-sm">
|
||||
{best !== undefined && e.time > best ? formatDelta(e.time - best) : ""}
|
||||
</td>
|
||||
<td className="hide-sm">
|
||||
<BonusChip multiplier={e.bonusMultiplier} position={e.position} completions={fresh.completions} />
|
||||
</td>
|
||||
<td className="num">
|
||||
<PointsValue points={e.points} raised={e.pointsRaised} />
|
||||
</td>
|
||||
<td className="hide-sm">
|
||||
<RecordedAt at={e.recordedAt} legacy={e.isLegacy} />
|
||||
</td>
|
||||
<td className="hide-sm">
|
||||
<AdminToggle kind="record" id={e.recordId} invalid={false} what={what} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<LoadMore hasMore={!!q.hasNextPage} loading={q.isFetchingNextPage} onClick={() => q.fetchNextPage()} />
|
||||
<p className="hint">
|
||||
Showing {serverLabel(fresh.serverTag)} times for {fresh.mapName}, stage {fresh.stage}, category {fresh.categoryNumber}.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { MapInfo } from "../api/types";
|
||||
import { InvalidChip, ServerTagChip } from "../components/Bits";
|
||||
import { Empty, ErrorBox, Loading } from "../components/States";
|
||||
import { formatNumber } from "../format";
|
||||
|
||||
export function mapSummary(m: MapInfo) {
|
||||
const cats = m.stages.flatMap((s) => s.categories);
|
||||
const servers = Array.from(new Set(cats.map((c) => c.serverTag))).sort();
|
||||
const completions = cats.reduce((sum, c) => sum + c.completions, 0);
|
||||
return { categories: cats.length, servers, completions };
|
||||
}
|
||||
|
||||
export function MapsTable({ maps }: { maps: MapInfo[] }) {
|
||||
return (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Map</th>
|
||||
<th className="num">Stages</th>
|
||||
<th className="num hide-sm">Categories</th>
|
||||
<th className="hide-sm">Servers</th>
|
||||
<th className="num">Records</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{maps.map((m) => {
|
||||
const s = mapSummary(m);
|
||||
return (
|
||||
<tr key={m.mapName} className={m.allCategoriesInvalid ? "row-invalid" : ""}>
|
||||
<td>
|
||||
<Link to={`/map/${encodeURIComponent(m.mapName)}`} className="map-link">
|
||||
{m.mapName}
|
||||
</Link>
|
||||
{m.allCategoriesInvalid && (
|
||||
<>
|
||||
{" "}
|
||||
<InvalidChip label="Disabled" />
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td className="num">{m.stages.length}</td>
|
||||
<td className="num hide-sm">{s.categories}</td>
|
||||
<td className="hide-sm">
|
||||
<span className="cell-chips">
|
||||
{s.servers.map((t) => (
|
||||
<ServerTagChip key={t} tag={t} />
|
||||
))}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">{formatNumber(s.completions)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapsPage() {
|
||||
const q = useQuery({ queryKey: ["allmaps"], queryFn: api.allMaps });
|
||||
const [text, setText] = useState("");
|
||||
const maps = (q.data ?? []).filter((m) => m.mapName.toLowerCase().includes(text.toLowerCase()));
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-head">
|
||||
<div>
|
||||
<h1>Maps</h1>
|
||||
<p className="lead">Every map with a racetimer. Open a map to see its stages, categories and leaderboards.</p>
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
placeholder="Filter maps"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
aria-label="Filter maps"
|
||||
/>
|
||||
</section>
|
||||
{q.isPending && <Loading />}
|
||||
{q.isError && <ErrorBox error={q.error} onRetry={() => q.refetch()} />}
|
||||
{q.isSuccess && maps.length === 0 && <Empty>No maps match “{text}”.</Empty>}
|
||||
{maps.length > 0 && <MapsTable maps={maps} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import { HISTORY_PAGE, PLAYER_MAPS_PAGE, SERVERS, type HistoryEntry, type Player, type PlayerMapRow } from "../api/types";
|
||||
import {
|
||||
hideBroken,
|
||||
BonusChip,
|
||||
CategoryName,
|
||||
InvalidatedBy,
|
||||
InvalidChip,
|
||||
PointsValue,
|
||||
LoadMore,
|
||||
RecordedAt,
|
||||
ServerTagChip,
|
||||
} from "../components/Bits";
|
||||
import { TimeChart } from "../components/TimeChart";
|
||||
import { AdminToggle } from "../components/AdminToggle";
|
||||
import { Empty, ErrorBox, isNotFound, Loading, NotFound } from "../components/States";
|
||||
import { formatNumber, formatTime, serverLabel } from "../format";
|
||||
import { useServer } from "../server";
|
||||
|
||||
type Tab = "records" | "history";
|
||||
|
||||
export function PlayerPage() {
|
||||
const { steamid = "" } = useParams();
|
||||
const { server } = useServer();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const tab: Tab = params.get("tab") === "history" ? "history" : "records";
|
||||
const categoryId = Number(params.get("category")) || undefined;
|
||||
|
||||
const player = useQuery({ queryKey: ["player", steamid, server], queryFn: () => api.player(steamid, server) });
|
||||
|
||||
if (player.isPending) return <Loading />;
|
||||
if (player.isError) {
|
||||
return isNotFound(player.error) ? (
|
||||
<NotFound what="player" />
|
||||
) : (
|
||||
<ErrorBox error={player.error} onRetry={() => player.refetch()} />
|
||||
);
|
||||
}
|
||||
const p = player.data;
|
||||
|
||||
const setTab = (t: Tab, category?: number) => {
|
||||
const next = new URLSearchParams();
|
||||
if (t === "history") next.set("tab", "history");
|
||||
if (category) next.set("category", String(category));
|
||||
setParams(next, { replace: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProfileHeader p={p} />
|
||||
<div className="tabs" role="tablist">
|
||||
<button role="tab" aria-selected={tab === "records"} className={tab === "records" ? "on" : ""} onClick={() => setTab("records")}>
|
||||
Records
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === "history"} className={tab === "history" ? "on" : ""} onClick={() => setTab("history")}>
|
||||
Improvement history
|
||||
</button>
|
||||
</div>
|
||||
{tab === "records" ? (
|
||||
<RecordsTab steamid={p.steamID} onShowHistory={(id) => setTab("history", id)} />
|
||||
) : (
|
||||
<HistoryTab steamid={p.steamID} categoryId={categoryId} onCategory={(id) => setTab("history", id)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileHeader({ p }: { p: Player }) {
|
||||
const { server } = useServer();
|
||||
return (
|
||||
<section className="profile">
|
||||
<img src={p.Avatar} alt="" className="profile-avatar" width={96} height={96} onError={hideBroken} />
|
||||
<div className="profile-main">
|
||||
<h1>{p.name}</h1>
|
||||
<div className="profile-ids">
|
||||
<code>{p.steamID}</code>
|
||||
{p.steamID64 && (
|
||||
<a href={`https://steamcommunity.com/profiles/${p.steamID64}`} target="_blank" rel="noreferrer">
|
||||
Steam profile ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{p.badges.length > 0 && (
|
||||
<div className="profile-badges">
|
||||
{p.badges.map((b) => (
|
||||
<span key={b.url} className="badge-pill">
|
||||
<img src={b.url} alt="" onError={hideBroken} />
|
||||
{b.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
{SERVERS.map((s) => {
|
||||
const st = p.servers[s];
|
||||
return (
|
||||
<div key={s} className={`stat ${s === server ? "stat-on" : ""}`}>
|
||||
<div className="stat-label">{serverLabel(s)} points</div>
|
||||
<div className="stat-value">{formatNumber(st.points)}</div>
|
||||
<div className="stat-sub">
|
||||
{st.times > 0 ? (
|
||||
<>
|
||||
Rank #{formatNumber(st.rank)} · {formatNumber(st.times)} records
|
||||
</>
|
||||
) : (
|
||||
"No records yet"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordsTab({ steamid, onShowHistory }: { steamid: string; onShowHistory: (categoryId: number) => void }) {
|
||||
const [serverFilter, setServerFilter] = useState<"all" | "ze1" | "ze2">("all");
|
||||
const [text, setText] = useState("");
|
||||
const q = useInfiniteQuery({
|
||||
queryKey: ["playerMaps", steamid],
|
||||
queryFn: ({ pageParam }) => api.playerMaps(steamid, pageParam),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (last, all) => (last.length < PLAYER_MAPS_PAGE ? undefined : all.length * PLAYER_MAPS_PAGE),
|
||||
});
|
||||
const rows: PlayerMapRow[] = q.data?.pages.flat() ?? [];
|
||||
const shown = rows.filter(
|
||||
(r) =>
|
||||
(serverFilter === "all" || r.serverTag === serverFilter) &&
|
||||
(text === "" || r.mapName.toLowerCase().includes(text.toLowerCase())),
|
||||
);
|
||||
|
||||
if (q.isPending) return <Loading />;
|
||||
if (q.isError) return <ErrorBox error={q.error} onRetry={() => q.refetch()} />;
|
||||
if (rows.length === 0) return <Empty>This player has no records yet.</Empty>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<div className="segmented" role="group" aria-label="Server filter">
|
||||
{(["all", "ze1", "ze2"] as const).map((s) => (
|
||||
<button key={s} aria-pressed={serverFilter === s} className={serverFilter === s ? "on" : ""} onClick={() => setServerFilter(s)}>
|
||||
{s === "all" ? "All" : serverLabel(s)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
placeholder="Filter maps"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
aria-label="Filter maps"
|
||||
/>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Map</th>
|
||||
<th className="num">Time</th>
|
||||
<th className="num">Place</th>
|
||||
<th className="hide-sm">Bonus</th>
|
||||
<th className="num">Points</th>
|
||||
<th className="hide-sm">Set</th>
|
||||
<th className="hide-sm" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((r) => (
|
||||
<tr key={r.recordId} className={r.categoryInvalid || r.isInvalid ? "row-invalid" : ""}>
|
||||
<td className="map-cell">
|
||||
<Link to={`/map/${encodeURIComponent(r.mapName)}?stage=${r.stage}&category=${r.categoryId}`} className="map-link">
|
||||
{r.mapName}
|
||||
</Link>
|
||||
<div className="map-meta">
|
||||
<span className="sub">
|
||||
Stage {r.stage} · <CategoryName number={r.categoryNumber} legacy={r.isLegacy} />
|
||||
</span>
|
||||
<ServerTagChip tag={r.serverTag} />
|
||||
{r.isInvalid && <InvalidChip label="Run invalidated" />}
|
||||
{r.categoryInvalid && <InvalidChip label="Category invalid" />}
|
||||
</div>
|
||||
{r.isInvalid && <InvalidatedBy info={r.invalidation} what="Run invalidated" className="small" />}
|
||||
{r.categoryInvalid && (
|
||||
<InvalidatedBy info={r.categoryInvalidation} what="Category invalidated" className="small" />
|
||||
)}
|
||||
</td>
|
||||
<td className={r.isInvalid ? "num mono strike" : "num mono"}>{formatTime(r.time)}</td>
|
||||
<td className="num">
|
||||
{r.isInvalid ? (
|
||||
<span className="muted" title="Invalidated runs have no position">
|
||||
–
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
#{formatNumber(r.position)}
|
||||
<span className="sub"> / {formatNumber(r.completions)}</span>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td className="hide-sm">
|
||||
{!r.isInvalid && (
|
||||
<BonusChip multiplier={r.bonusMultiplier} position={r.position} completions={r.completions} />
|
||||
)}
|
||||
</td>
|
||||
<td className={r.isInvalid ? "num muted" : "num strong"}>
|
||||
<PointsValue points={r.points} raised={r.pointsRaised} />
|
||||
</td>
|
||||
<td className="hide-sm">
|
||||
<RecordedAt at={r.recordedAt} legacy={r.isLegacy} />
|
||||
</td>
|
||||
<td className="hide-sm">
|
||||
<div className="row-actions">
|
||||
{!r.isLegacy && (
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => onShowHistory(r.categoryId)}>
|
||||
History
|
||||
</button>
|
||||
)}
|
||||
<AdminToggle kind="record" id={r.recordId} invalid={r.isInvalid} what={`this ${formatTime(r.time)} run`} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{shown.length === 0 && <Empty>No records match the filter.</Empty>}
|
||||
<LoadMore hasMore={!!q.hasNextPage} loading={q.isFetchingNextPage} onClick={() => q.fetchNextPage()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryTab({
|
||||
steamid,
|
||||
categoryId,
|
||||
onCategory,
|
||||
}: {
|
||||
steamid: string;
|
||||
categoryId?: number;
|
||||
onCategory: (id?: number) => void;
|
||||
}) {
|
||||
const maps = useQuery({
|
||||
queryKey: ["playerMapsAll", steamid],
|
||||
// Every category the player has a time in, for the filter drop-down.
|
||||
queryFn: async () => {
|
||||
const out: PlayerMapRow[] = [];
|
||||
for (let offset = 0; ; offset += PLAYER_MAPS_PAGE) {
|
||||
const page = await api.playerMaps(steamid, offset);
|
||||
out.push(...page);
|
||||
if (page.length < PLAYER_MAPS_PAGE) return out;
|
||||
}
|
||||
},
|
||||
});
|
||||
const q = useInfiniteQuery({
|
||||
queryKey: ["history", steamid, categoryId ?? 0],
|
||||
queryFn: ({ pageParam }) => api.playerHistory(steamid, pageParam, categoryId),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (last, all) => (last.length < HISTORY_PAGE ? undefined : all.length * HISTORY_PAGE),
|
||||
});
|
||||
const entries: HistoryEntry[] = q.data?.pages.flat() ?? [];
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
(maps.data ?? [])
|
||||
.filter((r) => !r.isLegacy)
|
||||
.sort((a, b) => a.mapName.localeCompare(b.mapName) || a.stage - b.stage || a.categoryNumber - b.categoryNumber),
|
||||
[maps.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<label className="field">
|
||||
<span>Category</span>
|
||||
<select
|
||||
className="input"
|
||||
value={categoryId ?? ""}
|
||||
onChange={(e) => onCategory(e.target.value ? Number(e.target.value) : undefined)}
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{options.map((r) => (
|
||||
<option key={r.categoryId} value={r.categoryId}>
|
||||
{r.mapName} · Stage {r.stage} · Category {r.categoryNumber} · {serverLabel(r.serverTag)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="hint">
|
||||
Every saved run was a new personal best when it was set. Classic (migrated) records have no date and are listed last.
|
||||
</p>
|
||||
{q.isPending && <Loading />}
|
||||
{q.isError && <ErrorBox error={q.error} onRetry={() => q.refetch()} />}
|
||||
{q.isSuccess && entries.length === 0 && <Empty>No improvements to show.</Empty>}
|
||||
{categoryId && entries.length > 0 && <TimeChart entries={entries} />}
|
||||
{entries.length > 0 && (
|
||||
<ol className="timeline">
|
||||
{entries.map((e) => (
|
||||
<li key={e.recordId} className={e.isInvalid ? "tl-invalid" : ""}>
|
||||
<div className="tl-when">
|
||||
<RecordedAt at={e.recordedAt} legacy={e.isLegacy} />
|
||||
</div>
|
||||
<div className="tl-body">
|
||||
<div className="tl-title">
|
||||
<span className="mono strong">{formatTime(e.time)}</span>
|
||||
{e.improvedBy !== null && e.improvedBy > 0 && (
|
||||
<span className="good">
|
||||
{" "}
|
||||
improved by {formatTime(e.improvedBy)}s
|
||||
</span>
|
||||
)}
|
||||
{e.previousTime === null && !e.isInvalid && <span className="muted"> first time</span>}
|
||||
{e.isCurrentBest && <span className="chip chip-best">Current best</span>}
|
||||
{e.isInvalid && <InvalidChip label="Run invalidated" />}
|
||||
</div>
|
||||
<div className="sub">
|
||||
{!categoryId ? (
|
||||
<button className="linklike" onClick={() => onCategory(e.categoryId)}>
|
||||
{e.mapName} · Stage {e.stage} · Category {e.categoryNumber}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{e.mapName} · Stage {e.stage} · Category {e.categoryNumber}
|
||||
</>
|
||||
)}{" "}
|
||||
· {serverLabel(e.serverTag)}
|
||||
{e.previousTime !== null && <> · previous best {formatTime(e.previousTime)}</>}
|
||||
</div>
|
||||
{e.isInvalid && <InvalidatedBy info={e.invalidation} what="Run invalidated" className="small" />}
|
||||
{e.categoryInvalid && (
|
||||
<InvalidatedBy info={e.categoryInvalidation} what="Category invalidated" className="small" />
|
||||
)}
|
||||
</div>
|
||||
<div className="tl-admin">
|
||||
<AdminToggle kind="record" id={e.recordId} invalid={e.isInvalid} what={`this ${formatTime(e.time)} run`} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
<LoadMore hasMore={!!q.hasNextPage} loading={q.isFetchingNextPage} onClick={() => q.fetchNextPage()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export function PointsPage() {
|
||||
return (
|
||||
<article className="prose">
|
||||
<h1>How points work</h1>
|
||||
<p className="lead">
|
||||
Every map stage can have several <strong>categories</strong>: one per set of server settings (gravity, air
|
||||
acceleration, tickrate and so on) and per server. Each category has its own leaderboard, and each gives points on its
|
||||
own.
|
||||
</p>
|
||||
|
||||
<h2>Points per category</h2>
|
||||
<ul>
|
||||
<li>Only your best valid time in a category counts. Players with the same time share the same place.</li>
|
||||
<li>
|
||||
On a board with <em>n</em> records, first place gets <em>n</em> points, second place <em>n − 1</em>, and so on.
|
||||
</li>
|
||||
<li>
|
||||
Once a board has <strong>200 or more records</strong>, only the faster half earns points. With 200 records the top 100
|
||||
do, with 210 records the top 105.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Top 5% bonus</h2>
|
||||
<p>
|
||||
On boards with at least 100 records, the fastest players get a multiplier that rises smoothly the closer they are to
|
||||
first place:
|
||||
</p>
|
||||
<ul>
|
||||
<li>4× for first place, sliding down to 2× at the top 1%</li>
|
||||
<li>2× at the top 1%, sliding down to no bonus at the top 5%</li>
|
||||
</ul>
|
||||
<p>The multiplier is shown next to each time on the leaderboards and on your profile.</p>
|
||||
|
||||
<h2>Classic records</h2>
|
||||
<p>
|
||||
Times from the old racetimer are kept as <strong>Classic</strong> categories. Their points are divided by 10, and they
|
||||
show as “Legacy record” because the exact date was not saved back then.
|
||||
</p>
|
||||
<p>
|
||||
On small Classic boards, dividing by 10 would give almost everyone the same few points. So two extra rules apply:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Every time that earns points gets <strong>at least 1 point</strong>.
|
||||
</li>
|
||||
<li>
|
||||
Each of the <strong>top 10 places</strong> gets at least 1 point more than the place below it.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
For example, a Classic board with 9 records gives 9, 8, 7 … 1 points instead of 1, 1, 1, 1, 1, 0, 0, 0, 0. On big
|
||||
Classic boards the top places are already far apart, so nothing changes there. Points that were raised by these rules are
|
||||
marked with <span className="raised-mark">↑</span> on the leaderboard.
|
||||
</p>
|
||||
|
||||
<h2>ZE1 and ZE2</h2>
|
||||
<p>Points and ranks are counted separately for each server. Use the ZE1 / ZE2 switch on the leaderboard.</p>
|
||||
|
||||
<h2>Invalid records</h2>
|
||||
<p>
|
||||
Admins can mark a single run or a whole category as invalid, for example if it was cheated or the map was broken. An
|
||||
invalid run gives no points and your previous valid time counts again. An invalid category stays visible but gives no
|
||||
points to anyone.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import { PlayerCell, Rank } from "../components/Bits";
|
||||
import { Empty, ErrorBox, Loading } from "../components/States";
|
||||
import { formatNumber, serverLabel } from "../format";
|
||||
import { useServer } from "../server";
|
||||
import { MapsTable } from "./MapsPage";
|
||||
|
||||
export function SearchPage() {
|
||||
const [params] = useSearchParams();
|
||||
const q = (params.get("q") ?? "").trim();
|
||||
const { server } = useServer();
|
||||
const players = useQuery({
|
||||
queryKey: ["searchPlayers", q, server],
|
||||
queryFn: () => api.searchPlayers(q, server),
|
||||
enabled: q.length > 0,
|
||||
});
|
||||
const maps = useQuery({ queryKey: ["allmaps"], queryFn: api.allMaps, enabled: q.length > 0 });
|
||||
const mapHits = (maps.data ?? []).filter((m) => m.mapName.toLowerCase().includes(q.toLowerCase()));
|
||||
|
||||
if (!q) return <Empty>Type a player name, Steam ID or map name in the search box.</Empty>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-head">
|
||||
<div>
|
||||
<h1>Search: “{q}”</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h2>Players</h2>
|
||||
{players.isPending && <Loading />}
|
||||
{players.isError && <ErrorBox error={players.error} onRetry={() => players.refetch()} />}
|
||||
{players.isSuccess && players.data.length === 0 && <Empty>No players found.</Empty>}
|
||||
{players.isSuccess && players.data.length > 0 && (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th className="num">{serverLabel(server)} rank</th>
|
||||
<th className="num">{serverLabel(server)} points</th>
|
||||
<th className="hide-sm">Steam ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{players.data.map((p) => (
|
||||
<tr key={p.steamID}>
|
||||
<td>
|
||||
<PlayerCell steamID={p.steamID} name={p.name} avatar={p.Avatar} badges={p.UrlBanners} />
|
||||
</td>
|
||||
<td className="num">{p.Rank > 0 ? <Rank value={p.Rank} /> : <span className="muted">–</span>}</td>
|
||||
<td className="num strong">{formatNumber(p.PlayerPoints)}</td>
|
||||
<td className="hide-sm mono muted">{p.steamID}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2>Maps</h2>
|
||||
{maps.isPending && <Loading />}
|
||||
{maps.isError && <ErrorBox error={maps.error} onRetry={() => maps.refetch()} />}
|
||||
{maps.isSuccess && mapHits.length === 0 && <Empty>No maps found.</Empty>}
|
||||
{mapHits.length > 0 && <MapsTable maps={mapHits} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import { SERVERS, type ServerTag } from "./api/types";
|
||||
|
||||
const KEY = "racetimer.server";
|
||||
|
||||
function initial(): ServerTag {
|
||||
try {
|
||||
const v = localStorage.getItem(KEY);
|
||||
if (v && (SERVERS as string[]).includes(v)) return v as ServerTag;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return "ze1";
|
||||
}
|
||||
|
||||
interface ServerState {
|
||||
server: ServerTag;
|
||||
setServer: (s: ServerTag) => void;
|
||||
}
|
||||
|
||||
const ServerContext = createContext<ServerState>({ server: "ze1", setServer: () => {} });
|
||||
|
||||
/** Which server's points (ZE1 or ZE2) the leaderboard and profiles show. Remembered per browser. */
|
||||
export function ServerProvider({ children }: { children: ReactNode }) {
|
||||
const [server, setState] = useState<ServerTag>(initial);
|
||||
const setServer = (s: ServerTag) => {
|
||||
setState(s);
|
||||
try {
|
||||
localStorage.setItem(KEY, s);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
return <ServerContext.Provider value={{ server, setServer }}>{children}</ServerContext.Provider>;
|
||||
}
|
||||
|
||||
export function useServer(): ServerState {
|
||||
return useContext(ServerContext);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// base "./" lets the built site be served from any folder on the web server.
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [react()],
|
||||
});
|
||||
Binary file not shown.
Reference in New Issue
Block a user