diff --git a/RaceTimer_2026/racetimer_endpoints/README.md b/RaceTimer_2026/racetimer_endpoints/README.md index dde633e..8f4a93e 100644 --- a/RaceTimer_2026/racetimer_endpoints/README.md +++ b/RaceTimer_2026/racetimer_endpoints/README.md @@ -18,6 +18,10 @@ All paths below are under `https:///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. | diff --git a/RaceTimer_2026/racetimer_endpoints/racetimer_invalidation_audit.sql b/RaceTimer_2026/racetimer_endpoints/racetimer_invalidation_audit.sql new file mode 100644 index 0000000..ec4ceb2 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/racetimer_invalidation_audit.sql @@ -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; diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/dto/Dto.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/dto/Dto.java index 2e8732c..4deec6f 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/dto/Dto.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/dto/Dto.java @@ -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; } diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/BoardEntry.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/BoardEntry.java index 2ab895e..d39588c 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/BoardEntry.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/BoardEntry.java @@ -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. */ diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/Category.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/Category.java index 6d0fc0e..50f9206 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/Category.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/Category.java @@ -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 entries = new ArrayList<>(); diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/Views.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/Views.java index 719f007..177e5b1 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/Views.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/Views.java @@ -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); diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/Points.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/Points.java index caa8547..564c3e2 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/Points.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/Points.java @@ -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; } diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/SnapshotBuilder.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/SnapshotBuilder.java index f94aec6..0070bb4 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/SnapshotBuilder.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/SnapshotBuilder.java @@ -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 list) { + Collections.sort(list, new Comparator() { + @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 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 rankedTags; private final String classicServerCvars; @@ -81,7 +112,7 @@ public final class SnapshotBuilder { Collection records, Map> badges) { - // --- Categories, numbered like the plugin: per map+stage, ordered by cvars_hash --- + // --- Categories, numbered per map+stage by their server settings --- Map categories = new HashMap<>(); Map> byMapStage = new HashMap<>(); for (CategoryRow r : categoryRows) { @@ -99,16 +130,7 @@ public final class SnapshotBuilder { list.add(c); } for (List list : byMapStage.values()) { - Collections.sort(list, new Comparator() { - @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 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) { diff --git a/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/rest/ApiTest.java b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/rest/ApiTest.java index f99be24..54b3d58 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/rest/ApiTest.java +++ b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/rest/ApiTest.java @@ -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()); diff --git a/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/PointsTest.java b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/PointsTest.java index d1a3a97..85dc6bf 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/PointsTest.java +++ b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/PointsTest.java @@ -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)); diff --git a/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/SnapshotBuilderTest.java b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/SnapshotBuilderTest.java index 9e64441..84a5efa 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/SnapshotBuilderTest.java +++ b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/SnapshotBuilderTest.java @@ -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 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()); diff --git a/RaceTimer_2026/racetimer_react/.env.example b/RaceTimer_2026/racetimer_react/.env.example new file mode 100644 index 0000000..f212742 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/.env.example @@ -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 diff --git a/RaceTimer_2026/racetimer_react/.gitignore b/RaceTimer_2026/racetimer_react/.gitignore new file mode 100644 index 0000000..f633501 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.env.local diff --git a/RaceTimer_2026/racetimer_react/README.md b/RaceTimer_2026/racetimer_react/README.md new file mode 100644 index 0000000..f5dc4b9 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/README.md @@ -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/` | 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/?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//` 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. diff --git a/RaceTimer_2026/racetimer_react/index.html b/RaceTimer_2026/racetimer_react/index.html new file mode 100644 index 0000000..cf7b583 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + UNLOZE Racetimer + + +
+ + + diff --git a/RaceTimer_2026/racetimer_react/package-lock.json b/RaceTimer_2026/racetimer_react/package-lock.json new file mode 100644 index 0000000..c85a4c7 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/package-lock.json @@ -0,0 +1,1819 @@ +{ + "name": "racetimer_react", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "racetimer_react", + "version": "2.0.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.4", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", + "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", + "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", + "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", + "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", + "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", + "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", + "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", + "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", + "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", + "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", + "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", + "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", + "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", + "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", + "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", + "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", + "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", + "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", + "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", + "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", + "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", + "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", + "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", + "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", + "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", + "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.104.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.0.tgz", + "integrity": "sha512-JrC2r/JQlXt7khBSdUpsgxNvybzOg+aITa+ARRMlP2AFo93Y8vIqz077rp+e61mJetSZ0T6AJbwW1JHer1vrPQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.104.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.0.tgz", + "integrity": "sha512-e1TZmDCQnWIfiDVryIHeA6Idj+Lfx1hORLOhXS/l5wcgvtVD4yYqbTDl378sGQKIi2cSgb0TEMC9cKK8GGoJEw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.104.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.26", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz", + "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001813", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001813.tgz", + "integrity": "sha512-zfjJo4rM0+fUomGDBW/xcDjhIwz/210DGvip2MAMDZ8KHcRPnOHmEgHPZP0UHlZoxr8fYKVJOqcORhYQcG4FKQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.440", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.440.tgz", + "integrity": "sha512-SghDzqdJokdz8zP8YNlvS74+CwLRoUPDGvP/gFA+HrKVw+pOshUAgx8pXR37xl/u16zGae746rFNWvZ/22kU2Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", + "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", + "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4", + "react-router": "6.30.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", + "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.5", + "@rollup/rollup-android-arm64": "4.63.5", + "@rollup/rollup-darwin-arm64": "4.63.5", + "@rollup/rollup-darwin-x64": "4.63.5", + "@rollup/rollup-freebsd-arm64": "4.63.5", + "@rollup/rollup-freebsd-x64": "4.63.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", + "@rollup/rollup-linux-arm-musleabihf": "4.63.5", + "@rollup/rollup-linux-arm64-gnu": "4.63.5", + "@rollup/rollup-linux-arm64-musl": "4.63.5", + "@rollup/rollup-linux-loong64-gnu": "4.63.5", + "@rollup/rollup-linux-loong64-musl": "4.63.5", + "@rollup/rollup-linux-ppc64-gnu": "4.63.5", + "@rollup/rollup-linux-ppc64-musl": "4.63.5", + "@rollup/rollup-linux-riscv64-gnu": "4.63.5", + "@rollup/rollup-linux-riscv64-musl": "4.63.5", + "@rollup/rollup-linux-s390x-gnu": "4.63.5", + "@rollup/rollup-linux-x64-gnu": "4.63.5", + "@rollup/rollup-linux-x64-musl": "4.63.5", + "@rollup/rollup-openbsd-x64": "4.63.5", + "@rollup/rollup-openharmony-arm64": "4.63.5", + "@rollup/rollup-win32-arm64-msvc": "4.63.5", + "@rollup/rollup-win32-ia32-msvc": "4.63.5", + "@rollup/rollup-win32-x64-gnu": "4.63.5", + "@rollup/rollup-win32-x64-msvc": "4.63.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/RaceTimer_2026/racetimer_react/package.json b/RaceTimer_2026/racetimer_react/package.json new file mode 100644 index 0000000..c773cb8 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/package.json @@ -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" + } +} diff --git a/RaceTimer_2026/racetimer_react/public/favicon.ico b/RaceTimer_2026/racetimer_react/public/favicon.ico new file mode 100644 index 0000000..a11777c Binary files /dev/null and b/RaceTimer_2026/racetimer_react/public/favicon.ico differ diff --git a/RaceTimer_2026/racetimer_react/src/App.tsx b/RaceTimer_2026/racetimer_react/src/App.tsx new file mode 100644 index 0000000..ce32e51 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/App.tsx @@ -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//. */ +function OldMapLink() { + const { mapname, stage } = useParams(); + return ; +} + +export function App({ loginError }: { loginError: string | null }) { + const [notice, setNotice] = useState(loginError); + return ( + setNotice(null)}> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/RaceTimer_2026/racetimer_react/src/api/client.ts b/RaceTimer_2026/racetimer_react/src/api/client.ts new file mode 100644 index 0000000..665dfd4 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/api/client.ts @@ -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(path: string, init?: RequestInit): Promise { + const headers: Record = { 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(`timers/leaderboard/${offset}?server=${server}`), + player: (steamId: string, server: ServerTag) => + request(`timers/player/${enc(steamId)}?server=${server}`), + playerMaps: (steamId: string, offset: number) => + request(`timers/player/maps/${enc(steamId)}/${offset}`), + playerHistory: (steamId: string, offset: number, categoryId?: number) => + request( + `timers/player/history/${enc(steamId)}/${offset}` + (categoryId ? `?categoryId=${categoryId}` : ""), + ), + allMaps: () => request("timers/allmaps"), + map: (mapName: string) => request(`timers/map/${enc(mapName)}`), + category: (id: number, offset: number) => request(`timers/category/${id}/${offset}`), + searchPlayers: (q: string, server: ServerTag) => + request(`timers/searchplayers/${enc(q)}?server=${server}`), + me: () => request("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`; diff --git a/RaceTimer_2026/racetimer_react/src/api/types.ts b/RaceTimer_2026/racetimer_react/src/api/types.ts new file mode 100644 index 0000000..c0ab903 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/api/types.ts @@ -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; + 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; diff --git a/RaceTimer_2026/racetimer_react/src/auth.tsx b/RaceTimer_2026/racetimer_react/src/auth.tsx new file mode 100644 index 0000000..5f21301 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/auth.tsx @@ -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({ user: null, signOut: () => {}, expired: () => {} }); + +const LOGIN_ERRORS: Record = { + 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 "/#token=..." + * or "/#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(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 {children}; +} + +export function useAuth(): AuthState { + return useContext(AuthContext); +} diff --git a/RaceTimer_2026/racetimer_react/src/components/AdminToggle.tsx b/RaceTimer_2026/racetimer_react/src/components/AdminToggle.tsx new file mode 100644 index 0000000..bc03c6f --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/components/AdminToggle.tsx @@ -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 ( + + ); + } + return ( + + + {action} {what}? + + + + {m.isError && {(m.error as Error).message}} + + ); +} diff --git a/RaceTimer_2026/racetimer_react/src/components/Bits.tsx b/RaceTimer_2026/racetimer_react/src/components/Bits.tsx new file mode 100644 index 0000000..f64db0f --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/components/Bits.tsx @@ -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) { + e.currentTarget.style.visibility = "hidden"; +} + +export function PlayerCell({ + steamID, + name, + avatar, + badges = [], +}: { + steamID: string; + name: string; + avatar?: string; + badges?: string[]; +}) { + return ( + + {avatar && } + {name} + {badges.length > 0 && ( + + )} + + ); +} + +/** #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 #{value}; +} + +export function ServerSwitch({ value, onChange }: { value: ServerTag; onChange: (s: ServerTag) => void }) { + return ( +
+ {SERVERS.map((s) => ( + + ))} +
+ ); +} + +export function ServerTagChip({ tag }: { tag: string }) { + return {serverLabel(tag)}; +} + +export function LegacyChip() { + return ( + + Classic + + ); +} + +export function InvalidChip({ label = "Invalid" }: { label?: string }) { + return ( + + ⚠ {label} + + ); +} + +/** + * "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 ? ( + + {name} + + ) : ( + {name} + ) + ) : ( + an admin + ); + return ( + + {what} by {who} + {info?.at != null ? ( + <> + {" · "} + + + ) : ( + !name && (before this was recorded) + )} + + ); +} + +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 ( + + {formatNumber(points)} + ↑ + + ); +} + +export function BonusChip({ + multiplier, + position, + completions, +}: { + multiplier: number; + position: number; + completions: number; +}) { + if (multiplier <= 1.0001) return –; + const band = bonusLabel(position, completions); + return ( + + ×{Number(multiplier.toFixed(2))} + {band && {band}} + + ); +} + +export function RecordedAt({ at, legacy }: { at: number | null; legacy: boolean }) { + if (legacy || at === null) { + return ( + + Legacy record + + ); + } + return ( + + ); +} + +export function CategoryName({ number, legacy }: { number: number; legacy: boolean }) { + return ( + + Category {number} + {legacy && } + + ); +} + +export function LoadMore({ + hasMore, + loading, + onClick, +}: { + hasMore: boolean; + loading: boolean; + onClick: () => void; +}) { + if (!hasMore) return null; + return ( +
+ +
+ ); +} diff --git a/RaceTimer_2026/racetimer_react/src/components/Layout.tsx b/RaceTimer_2026/racetimer_react/src/components/Layout.tsx new file mode 100644 index 0000000..4461191 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/components/Layout.tsx @@ -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 ( +
+
+
+ + + + UNLOZE Racetimer + + + +
+ setQ(e.target.value)} + placeholder="Search players or maps" + aria-label="Search players or maps" + /> +
+
+
+ + {user && ( +
+
+ + Signed in as {user.name} ({user.role}). Admin controls are shown on map boards and + profiles. + + +
+
+ )} + + {notice && ( +
+
+ {notice} + +
+
+ )} + +
{children}
+ + +
+ ); +} diff --git a/RaceTimer_2026/racetimer_react/src/components/States.tsx b/RaceTimer_2026/racetimer_react/src/components/States.tsx new file mode 100644 index 0000000..774545f --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/components/States.tsx @@ -0,0 +1,45 @@ +import { Link } from "react-router-dom"; +import { ApiError } from "../api/client"; + +export function Loading({ label = "Loading…" }: { label?: string }) { + return ( +
+
+ ); +} + +export function ErrorBox({ error, onRetry }: { error: unknown; onRetry?: () => void }) { + const message = error instanceof Error ? error.message : "Something went wrong."; + return ( +
+ {message} + {onRetry && ( + + )} +
+ ); +} + +export function isNotFound(error: unknown): boolean { + return error instanceof ApiError && error.status === 404; +} + +export function Empty({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function NotFound({ what = "page" }: { what?: string }) { + return ( +
+

Not found

+

This {what} does not exist.

+ + Back to the leaderboard + +
+ ); +} diff --git a/RaceTimer_2026/racetimer_react/src/components/TimeChart.tsx b/RaceTimer_2026/racetimer_react/src/components/TimeChart.tsx new file mode 100644 index 0000000..8401518 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/components/TimeChart.tsx @@ -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(null); + const svgRef = useRef(null); + const wrapRef = useRef(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) => { + 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 ( +
+
Best time over time (faster times are higher)
+
+ setHover(null)} + > + {ticks.map((v) => ( + + + + {formatTime(v)} + + + ))} + {dateTicks.map((t, i) => ( + + {new Date(t * 1000).toLocaleDateString("en-GB", { day: "numeric", month: "short" })} + + ))} + + {h && } + {points.map((p, i) => ( + + ))} + {/* Direct label on the latest point only. */} + + {formatTime(points[points.length - 1].v)} + + + {h && ( +
+ {formatTime(h.v)} + {h.e.improvedBy !== null && −{formatTime(h.e.improvedBy)}} +
+ {formatDate(h.t)} +
+ )} +
+
+ ); +} diff --git a/RaceTimer_2026/racetimer_react/src/format.ts b/RaceTimer_2026/racetimer_react/src/format.ts new file mode 100644 index 0000000..58c60f9 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/format.ts @@ -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); +} diff --git a/RaceTimer_2026/racetimer_react/src/main.tsx b/RaceTimer_2026/racetimer_react/src/main.tsx new file mode 100644 index 0000000..c292bd0 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/main.tsx @@ -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( + + + + + {/* Hash URLs (#/player/...) keep the old site's links working. */} + + + + + + + , +); diff --git a/RaceTimer_2026/racetimer_react/src/pages/LeaderboardPage.tsx b/RaceTimer_2026/racetimer_react/src/pages/LeaderboardPage.tsx new file mode 100644 index 0000000..9ba70a9 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/pages/LeaderboardPage.tsx @@ -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 ( + <> +
+
+

{serverLabel(server)} leaderboard

+

+ Players ranked by racetimer points on {serverLabel(server)}. ZE1 and ZE2 are ranked separately.{" "} + How points work +

+
+ +
+ + {q.isPending && } + {q.isError && q.refetch()} />} + {q.isSuccess && players.length === 0 && No one has a time on {serverLabel(server)} yet.} + + {players.length > 0 && ( +
+ + + + + + + + + + + {players.map((p) => ( + + + + + + + ))} + +
RankPlayerPointsRecords
+ + + + {formatNumber(p.PlayerPoints)}{formatNumber(p.Times)}
+
+ )} + q.fetchNextPage()} /> + + ); +} diff --git a/RaceTimer_2026/racetimer_react/src/pages/MapPage.tsx b/RaceTimer_2026/racetimer_react/src/pages/MapPage.tsx new file mode 100644 index 0000000..999b492 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/pages/MapPage.tsx @@ -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 ; + if (map.isError) { + return isNotFound(map.error) ? : 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 This map has no categories yet.; + 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 ( + <> +
+
+

{m.mapName}

+

+ {m.stages.length === 1 ? "1 stage" : `${m.stages.length} stages`} + {m.allCategoriesInvalid && ( + <> + {" "} + · + + )} +

+
+
+ + {m.stages.length > 1 && ( +
+ {m.stages.map((s) => ( + + ))} +
+ )} + + {stage.allCategoriesInvalid && ( +
+ Every category of stage {stage.stage} has been marked invalid by an admin, so this stage gives no points. +
+ )} + + select(stage.stage, c)} /> + + + + ); +} + +function CategoryPicker({ + stage, + selected, + onSelect, +}: { + stage: Stage; + selected: Category; + onSelect: (id: number) => void; +}) { + if (stage.categories.length <= 1) return null; + return ( +
+ {stage.categories.map((c) => ( + + ))} +
+ ); +} + +/** 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 ( +
+
+

+ Category {category.categoryNumber} + {category.isLegacy && } + {category.isInvalid && } +

+ +
+ + {category.isInvalid && ( +
+ + + + + . Its times are still listed, but none of them give points. + +
+ )} + + {category.isLegacy ? ( +

+ 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. +

+ ) : category.cvars.length > 0 ? ( + <> +

+ Server settings when these times were set. + {others.length > 0 && " Highlighted values differ from another category of this stage."} +

+
+ {category.cvars.map((c) => { + const diff = differs(c.name, c.value); + return ( +
+
{c.name}
+
+ {c.value} + {diff && (differs)} +
+
+ ); + })} +
+ + ) : ( +

{category.serverCvars}

+ )} +
+ ); +} + +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 ; + if (q.isError) return q.refetch()} />; + + // Position of the previous valid row, for drawing the points cut-off line. + let lastValidPosition = 0; + + return ( +
+
+

Leaderboard

+ + {formatNumber(fresh.completions)} {fresh.completions === 1 ? "record" : "records"} + {cut !== null && <> · top {formatNumber(cut)} earn points} + {fresh.completions >= 100 && <> · top 5% get a bonus} + +
+ {hasInvalid && ( +

+ Rows marked 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. +

+ )} + {hasRaised && ( +

+ ↑ 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. How points work +

+ )} + {entries.length === 0 ? ( + No times in this category yet. + ) : ( +
+ + + + + + + + + + + + + + {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 ( + + {showCut && ( + + + + )} + {e.isInvalid ? ( + + + + + + + + + ) : ( + + + + + + + + + + + )} + + ); + })} + +
#PlayerTimeGapBonusPointsSet +
Points cut-off: only the faster half of this board earns points
+ – + +
+ + +
+ +
{formatTime(e.time)} + + 0 + + + +
+ + + + {formatTime(e.time)} + {best !== undefined && e.time > best ? formatDelta(e.time - best) : ""} + + + + + + + + +
+
+ )} + q.fetchNextPage()} /> +

+ Showing {serverLabel(fresh.serverTag)} times for {fresh.mapName}, stage {fresh.stage}, category {fresh.categoryNumber}. +

+
+ ); +} diff --git a/RaceTimer_2026/racetimer_react/src/pages/MapsPage.tsx b/RaceTimer_2026/racetimer_react/src/pages/MapsPage.tsx new file mode 100644 index 0000000..c3373e7 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/pages/MapsPage.tsx @@ -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 ( +
+ + + + + + + + + + + + {maps.map((m) => { + const s = mapSummary(m); + return ( + + + + + + + + ); + })} + +
MapStagesCategoriesServersRecords
+ + {m.mapName} + + {m.allCategoriesInvalid && ( + <> + {" "} + + + )} + {m.stages.length}{s.categories} + + {s.servers.map((t) => ( + + ))} + + {formatNumber(s.completions)}
+
+ ); +} + +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 ( + <> +
+
+

Maps

+

Every map with a racetimer. Open a map to see its stages, categories and leaderboards.

+
+ setText(e.target.value)} + aria-label="Filter maps" + /> +
+ {q.isPending && } + {q.isError && q.refetch()} />} + {q.isSuccess && maps.length === 0 && No maps match “{text}”.} + {maps.length > 0 && } + + ); +} diff --git a/RaceTimer_2026/racetimer_react/src/pages/PlayerPage.tsx b/RaceTimer_2026/racetimer_react/src/pages/PlayerPage.tsx new file mode 100644 index 0000000..6e82781 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/pages/PlayerPage.tsx @@ -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 ; + if (player.isError) { + return isNotFound(player.error) ? ( + + ) : ( + 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 ( + <> + +
+ + +
+ {tab === "records" ? ( + setTab("history", id)} /> + ) : ( + setTab("history", id)} /> + )} + + ); +} + +function ProfileHeader({ p }: { p: Player }) { + const { server } = useServer(); + return ( +
+ +
+

{p.name}

+
+ {p.steamID} + {p.steamID64 && ( + + Steam profile ↗ + + )} +
+ {p.badges.length > 0 && ( +
+ {p.badges.map((b) => ( + + + {b.name} + + ))} +
+ )} +
+
+ {SERVERS.map((s) => { + const st = p.servers[s]; + return ( +
+
{serverLabel(s)} points
+
{formatNumber(st.points)}
+
+ {st.times > 0 ? ( + <> + Rank #{formatNumber(st.rank)} · {formatNumber(st.times)} records + + ) : ( + "No records yet" + )} +
+
+ ); + })} +
+
+ ); +} + +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 ; + if (q.isError) return q.refetch()} />; + if (rows.length === 0) return This player has no records yet.; + + return ( + <> +
+
+ {(["all", "ze1", "ze2"] as const).map((s) => ( + + ))} +
+ setText(e.target.value)} + aria-label="Filter maps" + /> +
+
+ + + + + + + + + + + + + {shown.map((r) => ( + + + + + + + + + + ))} + +
MapTimePlaceBonusPointsSet +
+ + {r.mapName} + +
+ + Stage {r.stage} · + + + {r.isInvalid && } + {r.categoryInvalid && } +
+ {r.isInvalid && } + {r.categoryInvalid && ( + + )} +
{formatTime(r.time)} + {r.isInvalid ? ( + + – + + ) : ( + <> + #{formatNumber(r.position)} + / {formatNumber(r.completions)} + + )} + + {!r.isInvalid && ( + + )} + + + + + +
+ {!r.isLegacy && ( + + )} + +
+
+
+ {shown.length === 0 && No records match the filter.} + 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 ( + <> +
+ +
+

+ Every saved run was a new personal best when it was set. Classic (migrated) records have no date and are listed last. +

+ {q.isPending && } + {q.isError && q.refetch()} />} + {q.isSuccess && entries.length === 0 && No improvements to show.} + {categoryId && entries.length > 0 && } + {entries.length > 0 && ( +
    + {entries.map((e) => ( +
  1. +
    + +
    +
    +
    + {formatTime(e.time)} + {e.improvedBy !== null && e.improvedBy > 0 && ( + + {" "} + improved by {formatTime(e.improvedBy)}s + + )} + {e.previousTime === null && !e.isInvalid && first time} + {e.isCurrentBest && Current best} + {e.isInvalid && } +
    +
    + {!categoryId ? ( + + ) : ( + <> + {e.mapName} · Stage {e.stage} · Category {e.categoryNumber} + + )}{" "} + · {serverLabel(e.serverTag)} + {e.previousTime !== null && <> · previous best {formatTime(e.previousTime)}} +
    + {e.isInvalid && } + {e.categoryInvalid && ( + + )} +
    +
    + +
    +
  2. + ))} +
+ )} + q.fetchNextPage()} /> + + ); +} diff --git a/RaceTimer_2026/racetimer_react/src/pages/PointsPage.tsx b/RaceTimer_2026/racetimer_react/src/pages/PointsPage.tsx new file mode 100644 index 0000000..6602272 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/pages/PointsPage.tsx @@ -0,0 +1,67 @@ +export function PointsPage() { + return ( +
+

How points work

+

+ Every map stage can have several categories: 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. +

+ +

Points per category

+
    +
  • Only your best valid time in a category counts. Players with the same time share the same place.
  • +
  • + On a board with n records, first place gets n points, second place n − 1, and so on. +
  • +
  • + Once a board has 200 or more records, only the faster half earns points. With 200 records the top 100 + do, with 210 records the top 105. +
  • +
+ +

Top 5% bonus

+

+ On boards with at least 100 records, the fastest players get a multiplier that rises smoothly the closer they are to + first place: +

+
    +
  • 4× for first place, sliding down to 2× at the top 1%
  • +
  • 2× at the top 1%, sliding down to no bonus at the top 5%
  • +
+

The multiplier is shown next to each time on the leaderboards and on your profile.

+ +

Classic records

+

+ Times from the old racetimer are kept as Classic categories. Their points are divided by 10, and they + show as “Legacy record” because the exact date was not saved back then. +

+

+ On small Classic boards, dividing by 10 would give almost everyone the same few points. So two extra rules apply: +

+
    +
  • + Every time that earns points gets at least 1 point. +
  • +
  • + Each of the top 10 places gets at least 1 point more than the place below it. +
  • +
+

+ 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 ↑ on the leaderboard. +

+ +

ZE1 and ZE2

+

Points and ranks are counted separately for each server. Use the ZE1 / ZE2 switch on the leaderboard.

+ +

Invalid records

+

+ 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. +

+
+ ); +} diff --git a/RaceTimer_2026/racetimer_react/src/pages/SearchPage.tsx b/RaceTimer_2026/racetimer_react/src/pages/SearchPage.tsx new file mode 100644 index 0000000..97d71db --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/pages/SearchPage.tsx @@ -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 Type a player name, Steam ID or map name in the search box.; + + return ( + <> +
+
+

Search: “{q}”

+
+
+ +

Players

+ {players.isPending && } + {players.isError && players.refetch()} />} + {players.isSuccess && players.data.length === 0 && No players found.} + {players.isSuccess && players.data.length > 0 && ( +
+ + + + + + + + + + + {players.data.map((p) => ( + + + + + + + ))} + +
Player{serverLabel(server)} rank{serverLabel(server)} pointsSteam ID
+ + {p.Rank > 0 ? : –}{formatNumber(p.PlayerPoints)}{p.steamID}
+
+ )} + +

Maps

+ {maps.isPending && } + {maps.isError && maps.refetch()} />} + {maps.isSuccess && mapHits.length === 0 && No maps found.} + {mapHits.length > 0 && } + + ); +} diff --git a/RaceTimer_2026/racetimer_react/src/server.tsx b/RaceTimer_2026/racetimer_react/src/server.tsx new file mode 100644 index 0000000..df4ea26 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/server.tsx @@ -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({ 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(initial); + const setServer = (s: ServerTag) => { + setState(s); + try { + localStorage.setItem(KEY, s); + } catch { + /* ignore */ + } + }; + return {children}; +} + +export function useServer(): ServerState { + return useContext(ServerContext); +} diff --git a/RaceTimer_2026/racetimer_react/src/styles.css b/RaceTimer_2026/racetimer_react/src/styles.css new file mode 100644 index 0000000..3f44c6a --- /dev/null +++ b/RaceTimer_2026/racetimer_react/src/styles.css @@ -0,0 +1,1043 @@ +/* ---------------------------------------------------------------- tokens */ +:root { + color-scheme: dark; + --bg: #0d1117; + --surface: #161b22; + --surface-2: #1c232d; + --surface-3: #242c38; + --border: #2a3340; + --text: #e6edf3; + --text-2: #b1bac4; + --muted: #8b96a3; + --accent: #ff5c62; + --accent-soft: rgba(255, 92, 98, 0.14); + --link: #6cb6ff; + --good: #3fb950; + --warn: #d29922; + --warn-soft: rgba(210, 153, 34, 0.12); + --danger: #f85149; + --danger-soft: rgba(248, 81, 73, 0.12); + --gold: #e3b341; + --silver: #c3cad3; + --bronze: #d08a4c; + --ze1: #3987e5; + --ze2: #9085e9; + --chart: #3987e5; + --radius: 10px; + --radius-sm: 6px; + --font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", Ubuntu, sans-serif; + --mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace; +} + +* { + box-sizing: border-box; +} +html, +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 15px/1.5 var(--font); + -webkit-font-smoothing: antialiased; +} +a { + color: var(--link); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} +h1, +h2, +h3 { + line-height: 1.25; + margin: 0 0 0.5rem; + font-weight: 650; +} +h1 { + font-size: 1.75rem; +} +h2 { + font-size: 1.2rem; + margin-top: 1.75rem; +} +h3 { + font-size: 1rem; +} +code, +.mono { + font-family: var(--mono); + font-variant-numeric: tabular-nums; +} +:focus-visible { + outline: 2px solid var(--link); + outline-offset: 2px; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +/* ---------------------------------------------------------------- layout */ +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} +.container { + width: 100%; + max-width: 1180px; + margin: 0 auto; + padding: 0 16px; +} +.main { + flex: 1; + padding-top: 24px; + padding-bottom: 48px; +} + +.topbar { + position: sticky; + top: 0; + z-index: 10; + background: rgba(13, 17, 23, 0.92); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border); +} +.topbar-inner { + max-width: 1180px; + margin: 0 auto; + padding: 10px 16px; + display: flex; + align-items: center; + gap: 20px; +} +.brand { + display: flex; + align-items: center; + gap: 8px; + color: var(--text); + font-weight: 700; + letter-spacing: 0.02em; + white-space: nowrap; +} +.brand:hover { + text-decoration: none; +} +.brand-mark { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 8px; + background: var(--accent-soft); + color: var(--accent); +} +.brand-sub { + color: var(--accent); +} +.mainnav { + display: flex; + gap: 4px; +} +.mainnav a { + color: var(--text-2); + padding: 6px 12px; + border-radius: var(--radius-sm); + font-weight: 500; +} +.mainnav a:hover { + color: var(--text); + background: var(--surface-2); + text-decoration: none; +} +.mainnav a.active { + color: var(--text); + background: var(--surface-3); +} +.search { + margin-left: auto; + flex: 0 1 280px; +} +.search input, +.input { + width: 100%; + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); + border-radius: var(--radius-sm); + padding: 8px 12px; + font: inherit; +} +.search input:focus, +.input:focus { + outline: none; + border-color: var(--link); +} +select.input { + cursor: pointer; +} + +.adminbar { + background: var(--warn-soft); + border-bottom: 1px solid rgba(210, 153, 34, 0.35); + font-size: 0.9rem; +} +.adminbar-inner { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding-top: 6px; + padding-bottom: 6px; +} + +.footer { + border-top: 1px solid var(--border); + color: var(--muted); + font-size: 0.875rem; +} +.footer-inner { + display: flex; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + padding-top: 18px; + padding-bottom: 18px; +} +.footer-admin { + color: var(--muted); +} + +.page-head { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 20px; +} +.page-head .input { + max-width: 280px; +} +.lead { + color: var(--text-2); + margin: 0; + max-width: 720px; +} +.hint { + color: var(--muted); + font-size: 0.875rem; + margin: 8px 0 12px; +} +.muted { + color: var(--muted); +} +.strong { + font-weight: 650; +} +.good { + color: var(--good); +} +.error-text { + color: var(--danger); + font-size: 0.85rem; +} +.sub { + color: var(--muted); + font-size: 0.8rem; +} + +/* ---------------------------------------------------------------- controls */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + background: var(--surface-3); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 14px; + font: inherit; + font-weight: 550; + cursor: pointer; + white-space: nowrap; +} +.btn:hover:not(:disabled) { + border-color: #3a4554; + background: #2b3441; + text-decoration: none; +} +.btn:disabled { + opacity: 0.6; + cursor: default; +} +.btn-sm { + padding: 3px 10px; + font-size: 0.8rem; +} +.btn-ghost { + background: transparent; +} +.btn-danger { + background: var(--danger); + border-color: var(--danger); + color: #fff; +} +.btn-danger:hover:not(:disabled) { + background: #ff6a61; + border-color: #ff6a61; +} +.btn-danger-ghost { + background: transparent; + color: var(--danger); + border-color: rgba(248, 81, 73, 0.4); +} +.btn-danger-ghost:hover:not(:disabled) { + background: var(--danger-soft); +} +.linklike { + background: none; + border: 0; + padding: 0; + color: var(--link); + font: inherit; + cursor: pointer; + text-align: left; +} +.linklike:hover { + text-decoration: underline; +} +.confirm { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} +.confirm-text { + font-size: 0.8rem; + color: var(--text-2); +} + +.segmented { + display: inline-flex; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 3px; +} +.segmented button { + background: none; + border: 0; + color: var(--text-2); + padding: 5px 14px; + border-radius: 6px; + font: inherit; + font-weight: 600; + cursor: pointer; +} +.segmented button.on { + background: var(--surface-3); + color: var(--text); +} + +.tabs { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--border); + margin: 20px 0 16px; + overflow-x: auto; +} +.tabs button { + background: none; + border: 0; + border-bottom: 2px solid transparent; + color: var(--text-2); + padding: 10px 14px; + font: inherit; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + margin-bottom: -1px; +} +.tabs button:hover { + color: var(--text); +} +.tabs button.on { + color: var(--text); + border-bottom-color: var(--accent); +} +.tab-flag { + color: var(--warn); +} + +.toolbar { + display: flex; + gap: 12px; + align-items: flex-end; + flex-wrap: wrap; + margin-bottom: 12px; +} +.toolbar .input { + max-width: 260px; +} +.field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.8rem; + color: var(--muted); + flex: 1 1 360px; +} +.field .input { + max-width: 560px; +} + +.loadmore { + display: flex; + justify-content: center; + margin: 16px 0; +} + +/* ---------------------------------------------------------------- chips */ +.chip { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.02em; + padding: 1px 7px; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--text-2); + background: var(--surface-2); + white-space: nowrap; + vertical-align: middle; +} +.chip-server { + color: var(--text); +} +.chip-ze1 { + border-color: rgba(57, 135, 229, 0.55); + background: rgba(57, 135, 229, 0.14); +} +.chip-ze2 { + border-color: rgba(144, 133, 233, 0.55); + background: rgba(144, 133, 233, 0.14); +} +.chip-legacy { + color: var(--text-2); +} +.chip-invalid { + color: var(--warn); + border-color: rgba(210, 153, 34, 0.45); + background: var(--warn-soft); +} +.chip-bonus { + color: var(--gold); + border-color: rgba(227, 179, 65, 0.45); + background: rgba(227, 179, 65, 0.1); + font-family: var(--mono); +} +.chip-bonus-band { + font-family: var(--font); + color: var(--text-2); + font-weight: 550; +} +.chip-best { + color: var(--good); + border-color: rgba(63, 185, 80, 0.45); + background: rgba(63, 185, 80, 0.1); + margin-left: 8px; +} +.cell-chips { + display: inline-flex; + gap: 4px; + flex-wrap: wrap; +} +.cat-name { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} +.cell-stack { + display: flex; + flex-direction: column; + gap: 3px; +} + +/* ---------------------------------------------------------------- tables */ +.table-wrap { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow-x: auto; +} +.table { + width: 100%; + border-collapse: collapse; +} +.table th, +.table td { + padding: 9px 12px; + text-align: left; + vertical-align: middle; + border-bottom: 1px solid var(--border); +} +.table th { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + font-weight: 650; + background: var(--surface-2); + white-space: nowrap; +} +.table tbody tr:last-child td { + border-bottom: 0; +} +.table tbody tr:hover td { + background: rgba(255, 255, 255, 0.02); +} +.table .num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.row-invalid td { + opacity: 0.72; +} +.run-invalid td { + background: var(--danger-soft); +} +.run-invalid:hover td { + background: var(--danger-soft) !important; +} +.strike { + text-decoration: line-through; + text-decoration-color: var(--danger); +} +.raised-mark { + color: var(--link); + font-weight: 700; +} +time.date { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.invalidated-by { + color: var(--text-2); +} +.invalidated-by.small { + display: block; + font-size: 0.78rem; + margin-top: 3px; + font-weight: 400; +} +.invalidated-by a { + color: inherit; + text-decoration: underline; + text-decoration-color: var(--border); +} +.invalidated-by a:hover { + color: var(--text); +} +.cell-with-chip { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.row-actions { + display: flex; + gap: 6px; + align-items: center; + justify-content: flex-end; +} +.cut-row td { + background: var(--danger-soft) !important; + color: var(--text-2); + font-size: 0.8rem; + text-align: center; + padding: 5px 12px; +} + +.player-cell { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--text); + font-weight: 550; + min-width: 0; +} +.player-cell:hover .player-name { + text-decoration: underline; +} +.player-cell:hover { + text-decoration: none; +} +.player-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 260px; +} +.avatar { + width: 28px; + height: 28px; + border-radius: 6px; + background: var(--surface-3); + flex: none; +} +.badges { + display: inline-flex; + gap: 3px; +} +.badges img { + height: 16px; +} +.map-link { + color: var(--text); + font-weight: 550; + overflow-wrap: anywhere; +} +.map-cell { + min-width: 180px; +} +.map-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 6px; + margin-top: 2px; +} +.map-meta .cat-name { + gap: 4px; +} + +.rank { + font-weight: 700; + color: var(--text-2); +} +.rank.gold { + color: var(--gold); +} +.rank.silver { + color: var(--silver); +} +.rank.bronze { + color: var(--bronze); +} + +/* ---------------------------------------------------------------- states */ +.state { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--muted); + padding: 40px 16px; + text-align: center; + flex-direction: column; +} +.state.empty { + background: var(--surface); + border: 1px dashed var(--border); + border-radius: var(--radius); + margin: 12px 0; +} +.spinner { + width: 22px; + height: 22px; + border: 3px solid var(--surface-3); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.alert { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-radius: var(--radius-sm); + margin: 12px 0; + border: 1px solid; +} +.alert-danger { + background: var(--danger-soft); + border-color: rgba(248, 81, 73, 0.4); +} +.alert-warn { + background: var(--warn-soft); + border-color: rgba(210, 153, 34, 0.4); +} + +/* ---------------------------------------------------------------- profile */ +.profile { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 20px; + align-items: center; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; +} +.profile-avatar { + width: 96px; + height: 96px; + border-radius: 12px; + background: var(--surface-3); +} +.profile-main h1 { + margin-bottom: 4px; + word-break: break-word; +} +.profile-ids { + display: flex; + gap: 14px; + flex-wrap: wrap; + color: var(--muted); + font-size: 0.875rem; +} +.profile-badges { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 10px; +} +.badge-pill { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 999px; + padding: 2px 10px 2px 4px; + font-size: 0.8rem; +} +.badge-pill img { + height: 18px; +} +.stat-row { + display: flex; + gap: 12px; +} +.stat { + min-width: 160px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px 14px; +} +.stat-on { + border-color: rgba(255, 92, 98, 0.5); +} +.stat-label { + font-size: 0.8rem; + color: var(--muted); +} +.stat-value { + font-size: 1.6rem; + font-weight: 650; +} +.stat-sub { + font-size: 0.8rem; + color: var(--text-2); +} + +/* ---------------------------------------------------------------- history */ +.timeline { + list-style: none; + margin: 0; + padding: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.timeline li { + display: grid; + grid-template-columns: 140px 1fr auto; + gap: 14px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + align-items: center; +} +.timeline li:last-child { + border-bottom: 0; +} +.tl-when { + color: var(--text-2); + font-size: 0.875rem; +} +.tl-title { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; +} +.tl-invalid { + background: var(--warn-soft); +} +.tl-invalid .tl-title .mono { + text-decoration: line-through; + color: var(--muted); +} + +/* ---------------------------------------------------------------- map page */ +.map-title { + word-break: break-all; +} +.cat-picker { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; + margin-bottom: 16px; +} +.cat-card { + display: flex; + flex-direction: column; + gap: 4px; + text-align: left; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 14px; + color: var(--text); + font: inherit; + cursor: pointer; +} +.cat-card:hover { + border-color: #3a4554; +} +.cat-card.on { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent) inset; +} +.cat-card.is-invalid { + opacity: 0.8; +} +.cat-card-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 650; +} +.cat-card-sub { + color: var(--muted); + font-size: 0.82rem; +} +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px 18px; + margin-bottom: 20px; +} +.panel-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +.panel-head h2 { + margin: 0; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.cvars { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 8px; + margin: 0; +} +.cvar { + display: flex; + justify-content: space-between; + gap: 10px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 6px 10px; + font-family: var(--mono); + font-size: 0.82rem; +} +.cvar dt { + color: var(--text-2); +} +.cvar dd { + margin: 0; + font-weight: 650; +} +.cvar.diff { + border-color: rgba(227, 179, 65, 0.6); + background: rgba(227, 179, 65, 0.1); +} +.cvar.diff dd::after { + content: " ≠"; + color: var(--gold); +} +.board-head { + display: flex; + align-items: baseline; + gap: 12px; + flex-wrap: wrap; + margin: 8px 0 10px; +} +.board-head h2 { + margin: 0; +} +.invalidated { + margin-top: 24px; +} + +/* ---------------------------------------------------------------- chart */ +.chart { + margin: 0 0 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px 8px; +} +.chart-title { + font-weight: 600; + margin-bottom: 6px; +} +.chart-wrap { + position: relative; +} +.chart svg { + display: block; + width: 100%; + height: auto; +} +.chart-grid { + stroke: var(--border); + stroke-width: 1; +} +.chart-axis { + fill: var(--muted); + font-size: 11px; + font-family: var(--mono); +} +.chart-line { + fill: none; + stroke: var(--chart); + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} +.chart-dot { + fill: var(--chart); + stroke: var(--surface); + stroke-width: 2; +} +.chart-crosshair { + stroke: var(--muted); + stroke-width: 1; +} +.chart-label { + fill: var(--text); + font-size: 12px; + font-weight: 650; + font-family: var(--mono); +} +.chart-tip { + position: absolute; + + background: var(--surface-3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 6px 10px; + font-size: 0.8rem; + pointer-events: none; + white-space: nowrap; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); +} + +/* ---------------------------------------------------------------- prose */ +.prose { + max-width: 760px; +} +.prose p, +.prose li { + color: var(--text-2); +} +.prose strong, +.prose em { + color: var(--text); +} + +/* ---------------------------------------------------------------- small screens */ +@media (max-width: 820px) { + .topbar-inner { + flex-wrap: wrap; + gap: 8px 12px; + } + .search { + order: 3; + flex: 1 1 100%; + } + .profile { + grid-template-columns: auto 1fr; + } + .stat-row { + grid-column: 1 / -1; + } + .stat { + flex: 1; + min-width: 0; + } +} +@media (max-width: 640px) { + h1 { + font-size: 1.4rem; + } + .hide-sm { + display: none; + } + .table th, + .table td { + padding: 8px; + } + .player-name { + max-width: 150px; + } + .profile-avatar { + width: 64px; + height: 64px; + } + .timeline li { + grid-template-columns: 1fr; + gap: 4px; + } + .adminbar-inner { + flex-direction: column; + align-items: flex-start; + } + .page-head .input, + .toolbar .input { + max-width: none; + } +} + +.table time, +.table td > .muted { + white-space: nowrap; +} diff --git a/RaceTimer_2026/racetimer_react/tsconfig.json b/RaceTimer_2026/racetimer_react/tsconfig.json new file mode 100644 index 0000000..0fb7953 --- /dev/null +++ b/RaceTimer_2026/racetimer_react/tsconfig.json @@ -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"] +} diff --git a/RaceTimer_2026/racetimer_react/vite.config.ts b/RaceTimer_2026/racetimer_react/vite.config.ts new file mode 100644 index 0000000..82ca2af --- /dev/null +++ b/RaceTimer_2026/racetimer_react/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()], +}); diff --git a/RaceTimer_2026/racetimer_react_2026_build.zip b/RaceTimer_2026/racetimer_react_2026_build.zip new file mode 100644 index 0000000..6203808 Binary files /dev/null and b/RaceTimer_2026/racetimer_react_2026_build.zip differ