aboutsummaryrefslogtreecommitdiff
path: root/web/pw-server/src/routes
diff options
context:
space:
mode:
Diffstat (limited to 'web/pw-server/src/routes')
-rw-r--r--web/pw-server/src/routes/__layout.svelte49
-rw-r--r--web/pw-server/src/routes/bots/[bot_name].svelte56
-rw-r--r--web/pw-server/src/routes/bots/new.svelte1
-rw-r--r--web/pw-server/src/routes/docs.svelte14
-rw-r--r--web/pw-server/src/routes/editor.svelte255
-rw-r--r--web/pw-server/src/routes/index.svelte321
-rw-r--r--web/pw-server/src/routes/leaderboard.svelte28
-rw-r--r--web/pw-server/src/routes/matches/[match_id].svelte40
-rw-r--r--web/pw-server/src/routes/matches/index.svelte129
-rw-r--r--web/pw-server/src/routes/style.css14
10 files changed, 580 insertions, 327 deletions
diff --git a/web/pw-server/src/routes/__layout.svelte b/web/pw-server/src/routes/__layout.svelte
index 065a82c..86acf5b 100644
--- a/web/pw-server/src/routes/__layout.svelte
+++ b/web/pw-server/src/routes/__layout.svelte
@@ -6,16 +6,29 @@
<div class="outer-container">
<div class="navbar">
- <div class="navbar-main">
- <a href="/">PlanetWars</a>
+ <div class="navbar-left">
+ <div class="navbar-header">
+ <a href="/">PlanetWars</a>
+ </div>
+ <div class="navbar-item">
+ <a href="/editor">Editor</a>
+ </div>
+ <div class="navbar-item">
+ <a href="/leaderboard">Leaderboard</a>
+ </div>
+ <div class="navbar-item">
+ <a href="/docs">How to play</a>
+ </div>
+ </div>
+ <div class="navbar-right">
+ <UserControls />
</div>
- <UserControls />
</div>
<slot />
</div>
-<style lang="scss">
- @import "src/styles/variables.scss";
+<style lang="scss" global>
+ @import "src/styles/global.scss";
.outer-container {
width: 100vw;
@@ -34,13 +47,33 @@
padding: 0 15px;
}
- .navbar-main {
+ .navbar-left {
+ display: flex;
+ }
+
+ .navbar-right {
+ display: flex;
+ }
+
+ .navbar-header {
margin: auto 0;
+ padding-right: 24px;
}
- .navbar-main a {
+ .navbar-header a {
font-size: 20px;
- color: #eee;
+ color: #fff;
+ text-decoration: none;
+ }
+ .navbar-item {
+ margin: auto 0;
+ padding: 0 8px;
+ }
+
+ .navbar-item a {
+ font-size: 14px;
+ color: #fff;
text-decoration: none;
+ font-weight: 600;
}
</style>
diff --git a/web/pw-server/src/routes/bots/[bot_name].svelte b/web/pw-server/src/routes/bots/[bot_name].svelte
index 33a522f..5fe4cc7 100644
--- a/web/pw-server/src/routes/bots/[bot_name].svelte
+++ b/web/pw-server/src/routes/bots/[bot_name].svelte
@@ -1,18 +1,16 @@
<script lang="ts" context="module">
- import { get_session_token } from "$lib/auth";
+ import { ApiClient } from "$lib/api_client";
export async function load({ params, fetch }) {
- const token = get_session_token();
- const res = await fetch(`/api/bots/${params["bot_name"]}`, {
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${token}`,
- },
- });
-
- if (res.ok) {
- const { bot, owner, versions } = await res.json();
- // sort most recent first
+ const apiClient = new ApiClient(fetch);
+
+ try {
+ const [botData, matchesPage] = await Promise.all([
+ apiClient.get(`/api/bots/${params["bot_name"]}`),
+ apiClient.get("/api/matches", { bot: params["bot_name"], count: "20" }),
+ ]);
+
+ const { bot, owner, versions } = botData;
versions.sort((a: string, b: string) =>
dayjs(a["created_at"]).isAfter(b["created_at"]) ? -1 : 1
);
@@ -21,25 +19,28 @@
bot,
owner,
versions,
+ matches: matchesPage["matches"],
},
};
+ } catch (error) {
+ return {
+ status: error.status,
+ error: error,
+ };
}
-
- return {
- status: res.status,
- error: new Error("Could not find bot"),
- };
}
</script>
<script lang="ts">
import dayjs from "dayjs";
-
import { currentUser } from "$lib/stores/current_user";
+ import MatchList from "$lib/components/matches/MatchList.svelte";
+ import LinkButton from "$lib/components/LinkButton.svelte";
export let bot: object;
export let owner: object;
export let versions: object[];
+ export let matches: object[];
// function last_updated() {
// versions.sort()
@@ -92,7 +93,17 @@
</div>
{/if}
- <div class="versions">
+ <div class="matches">
+ <h3>Recent matches</h3>
+ <MatchList {matches} />
+ {#if matches.length > 0}
+ <div class="btn-container">
+ <LinkButton href={`/matches?bot=${bot["name"]}`}>All matches</LinkButton>
+ </div>
+ {/if}
+ </div>
+
+ <!-- <div class="versions">
<h4>Versions</h4>
<ul class="version-list">
{#each versions as version}
@@ -104,7 +115,7 @@
{#if versions.length == 0}
This bot does not have any versions yet.
{/if}
- </div>
+ </div> -->
</div>
<style lang="scss">
@@ -136,6 +147,11 @@
margin-bottom: $header-space-above-line;
}
+ .btn-container {
+ padding: 24px;
+ text-align: center;
+ }
+
.versions {
margin: 30px 0;
}
diff --git a/web/pw-server/src/routes/bots/new.svelte b/web/pw-server/src/routes/bots/new.svelte
index 7cb7229..c243fe1 100644
--- a/web/pw-server/src/routes/bots/new.svelte
+++ b/web/pw-server/src/routes/bots/new.svelte
@@ -16,6 +16,7 @@
async function createBot() {
saveErrors = [];
+ // TODO: how can we handle this with the new ApiClient?
let response = await fetch("/api/bots", {
method: "POST",
headers: {
diff --git a/web/pw-server/src/routes/docs.svelte b/web/pw-server/src/routes/docs.svelte
new file mode 100644
index 0000000..c7357c0
--- /dev/null
+++ b/web/pw-server/src/routes/docs.svelte
@@ -0,0 +1,14 @@
+<script>
+ import RulesView from "$lib/components/RulesView.svelte";
+</script>
+
+<div class="container">
+ <RulesView />
+</div>
+
+<style scoped lang="scss">
+ .container {
+ max-width: 800px;
+ margin: 0 auto;
+ }
+</style>
diff --git a/web/pw-server/src/routes/editor.svelte b/web/pw-server/src/routes/editor.svelte
new file mode 100644
index 0000000..ff8232c
--- /dev/null
+++ b/web/pw-server/src/routes/editor.svelte
@@ -0,0 +1,255 @@
+<script lang="ts">
+ import Visualizer from "$lib/components/Visualizer.svelte";
+ import EditorView from "$lib/components/EditorView.svelte";
+ import { onMount } from "svelte";
+ import { DateTime } from "luxon";
+
+ import type { Ace } from "ace-builds";
+ import ace from "ace-builds/src-noconflict/ace?client";
+ import * as AcePythonMode from "ace-builds/src-noconflict/mode-python?client";
+ import { getBotCode, saveBotCode } from "$lib/bot_code";
+ import { matchHistory } from "$lib/stores/editor_state";
+ import { debounce } from "$lib/utils";
+ import SubmitPane from "$lib/components/SubmitPane.svelte";
+ import OutputPane from "$lib/components/OutputPane.svelte";
+ import BotName from "./bots/[bot_name].svelte";
+
+ enum ViewMode {
+ Editor,
+ MatchVisualizer,
+ }
+
+ let viewMode = ViewMode.Editor;
+ let selectedMatchId: string | undefined = undefined;
+ let selectedMatchLog: string | undefined = undefined;
+
+ let editSession: Ace.EditSession;
+
+ onMount(() => {
+ init_editor();
+ });
+
+ function init_editor() {
+ editSession = new ace.EditSession(getBotCode());
+ editSession.setMode(new AcePythonMode.Mode());
+
+ const saveCode = () => {
+ const code = editSession.getDocument().getValue();
+ saveBotCode(code);
+ };
+
+ // cast to any because the type annotations are wrong here
+ (editSession as any).on("change", debounce(saveCode, 2000));
+ }
+
+ async function onMatchCreated(e: CustomEvent) {
+ const matchData = e.detail["match"];
+ matchHistory.pushMatch(matchData);
+ await selectMatch(matchData["id"]);
+ }
+
+ async function selectMatch(matchId: string) {
+ selectedMatchId = matchId;
+ selectedMatchLog = null;
+ fetchSelectedMatchLog(matchId);
+
+ viewMode = ViewMode.MatchVisualizer;
+ }
+
+ async function fetchSelectedMatchLog(matchId: string) {
+ if (matchId !== selectedMatchId) {
+ return;
+ }
+
+ let matchLog = await getMatchLog(matchId);
+
+ if (matchLog) {
+ selectedMatchLog = matchLog;
+ } else {
+ // try again in 1 second
+ setTimeout(fetchSelectedMatchLog, 1000, matchId);
+ }
+ }
+
+ async function getMatchData(matchId: string) {
+ let response = await fetch(`/api/matches/${matchId}`, {
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+
+ if (!response.ok) {
+ throw Error(response.statusText);
+ }
+
+ let matchData = await response.json();
+ return matchData;
+ }
+
+ async function getMatchLog(matchId: string) {
+ const matchData = await getMatchData(matchId);
+ console.log(matchData);
+ if (matchData["state"] !== "Finished") {
+ // log is not available yet
+ return null;
+ }
+
+ const res = await fetch(`/api/matches/${matchId}/log`, {
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+
+ let log = await res.text();
+ return log;
+ }
+
+ function setViewMode(viewMode_: ViewMode) {
+ selectedMatchId = undefined;
+ selectedMatchLog = undefined;
+ viewMode = viewMode_;
+ }
+
+ function formatMatchTimestamp(timestampString: string): string {
+ let timestamp = DateTime.fromISO(timestampString, { zone: "utc" }).toLocal();
+ if (timestamp.startOf("day").equals(DateTime.now().startOf("day"))) {
+ return timestamp.toFormat("HH:mm");
+ } else {
+ return timestamp.toFormat("dd/MM");
+ }
+ }
+
+ $: selectedMatch = $matchHistory.find((m) => m["id"] === selectedMatchId);
+</script>
+
+<div class="container">
+ <div class="sidebar-left">
+ <div
+ class="editor-button sidebar-item"
+ class:selected={viewMode === ViewMode.Editor}
+ on:click={() => setViewMode(ViewMode.Editor)}
+ >
+ Code
+ </div>
+ <div class="sidebar-header">match history</div>
+ <ul class="match-list">
+ {#each $matchHistory as match}
+ <li
+ class="match-card sidebar-item"
+ on:click={() => selectMatch(match.id)}
+ class:selected={match.id === selectedMatchId}
+ >
+ <div class="match-timestamp">{formatMatchTimestamp(match.timestamp)}</div>
+ <div class="match-card-body">
+ <!-- ugly temporary hardcode -->
+ <div class="match-opponent">{match["players"][1]["bot_name"]}</div>
+ <div class="match-map">{match["map"]?.name}</div>
+ </div>
+ </li>
+ {/each}
+ </ul>
+ </div>
+ <div class="editor-container">
+ {#if viewMode === ViewMode.MatchVisualizer}
+ <Visualizer matchData={selectedMatch} matchLog={selectedMatchLog} />
+ {:else if viewMode === ViewMode.Editor}
+ <EditorView {editSession} />
+ {/if}
+ </div>
+ <div class="sidebar-right">
+ {#if viewMode === ViewMode.MatchVisualizer}
+ <OutputPane matchLog={selectedMatchLog} />
+ {:else if viewMode === ViewMode.Editor}
+ <SubmitPane {editSession} on:matchCreated={onMatchCreated} />
+ {/if}
+ </div>
+</div>
+
+<style lang="scss">
+ @import "src/styles/variables.scss";
+
+ .container {
+ display: flex;
+ flex-grow: 1;
+ min-height: 0;
+ }
+
+ .sidebar-left {
+ width: 240px;
+ background-color: $bg-color;
+ display: flex;
+ flex-direction: column;
+ }
+ .sidebar-right {
+ width: 400px;
+ background-color: white;
+ border-left: 1px solid;
+ padding: 0;
+ display: flex;
+ overflow: hidden;
+ }
+ .editor-container {
+ flex-grow: 1;
+ flex-shrink: 1;
+ overflow: hidden;
+ background-color: white;
+ }
+
+ .editor-container {
+ height: 100%;
+ }
+
+ .sidebar-item {
+ color: #eee;
+ padding: 15px;
+ }
+
+ .sidebar-item:hover {
+ background-color: #333;
+ }
+
+ .sidebar-item.selected {
+ background-color: #333;
+ }
+
+ .match-list {
+ list-style: none;
+ color: #eee;
+ padding-top: 15px;
+ overflow-y: scroll;
+ padding-left: 0px;
+ }
+
+ .match-card {
+ padding: 10px 15px;
+ font-size: 11pt;
+ display: flex;
+ }
+
+ .match-timestamp {
+ color: #ccc;
+ }
+
+ .match-card-body {
+ margin: 0 8px;
+ }
+
+ .match-opponent {
+ font-weight: 600;
+ color: #eee;
+ }
+
+ .match-map {
+ color: #ccc;
+ }
+
+ .sidebar-header {
+ margin-top: 2em;
+ text-transform: uppercase;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 14px;
+ font-family: "Open Sans", sans-serif;
+ padding-left: 14px;
+ }
+</style>
diff --git a/web/pw-server/src/routes/index.svelte b/web/pw-server/src/routes/index.svelte
index 85c2454..fd1f505 100644
--- a/web/pw-server/src/routes/index.svelte
+++ b/web/pw-server/src/routes/index.svelte
@@ -1,277 +1,92 @@
-<script lang="ts">
- import Visualizer from "$lib/components/Visualizer.svelte";
- import EditorView from "$lib/components/EditorView.svelte";
- import { onMount } from "svelte";
-
- import { DateTime } from "luxon";
-
- import type { Ace } from "ace-builds";
- import ace from "ace-builds/src-noconflict/ace?client";
- import * as AcePythonMode from "ace-builds/src-noconflict/mode-python?client";
- import { getBotCode, saveBotCode, hasBotCode } from "$lib/bot_code";
- import { debounce } from "$lib/utils";
- import SubmitPane from "$lib/components/SubmitPane.svelte";
- import OutputPane from "$lib/components/OutputPane.svelte";
- import RulesView from "$lib/components/RulesView.svelte";
- import Leaderboard from "$lib/components/Leaderboard.svelte";
-
- enum ViewMode {
- Editor,
- MatchVisualizer,
- Rules,
- Leaderboard,
- }
-
- let matches = [];
-
- let viewMode = ViewMode.Editor;
- let selectedMatchId: string | undefined = undefined;
- let selectedMatchLog: string | undefined = undefined;
-
- let editSession: Ace.EditSession;
-
- onMount(() => {
- if (!hasBotCode()) {
- viewMode = ViewMode.Rules;
- }
- init_editor();
- });
-
- function init_editor() {
- editSession = new ace.EditSession(getBotCode());
- editSession.setMode(new AcePythonMode.Mode());
-
- const saveCode = () => {
- const code = editSession.getDocument().getValue();
- saveBotCode(code);
- };
-
- // cast to any because the type annotations are wrong here
- (editSession as any).on("change", debounce(saveCode, 2000));
- }
-
- async function onMatchCreated(e: CustomEvent) {
- const matchData = e.detail["match"];
- matches.unshift(matchData);
- matches = matches;
- await selectMatch(matchData["id"]);
- }
-
- async function selectMatch(matchId: string) {
- selectedMatchId = matchId;
- selectedMatchLog = null;
- fetchSelectedMatchLog(matchId);
-
- viewMode = ViewMode.MatchVisualizer;
- }
-
- async function fetchSelectedMatchLog(matchId: string) {
- if (matchId !== selectedMatchId) {
- return;
- }
-
- let matchLog = await getMatchLog(matchId);
-
- if (matchLog) {
- selectedMatchLog = matchLog;
- } else {
- // try again in 1 second
- setTimeout(fetchSelectedMatchLog, 1000, matchId);
+<script lang="ts" context="module">
+ import { ApiClient } from "$lib/api_client";
+
+ const NUM_MATCHES = "25";
+
+ export async function load({ fetch }) {
+ try {
+ const apiClient = new ApiClient(fetch);
+
+ let { matches, has_next } = await apiClient.get("/api/matches", {
+ count: NUM_MATCHES,
+ });
+
+ return {
+ props: {
+ matches,
+ hasNext: has_next,
+ },
+ };
+ } catch (error) {
+ return {
+ status: error.status,
+ error: new Error("failed to load matches"),
+ };
}
}
+</script>
- async function getMatchData(matchId: string) {
- let response = await fetch(`/api/matches/${matchId}`, {
- headers: {
- "Content-Type": "application/json",
- },
- });
+<script lang="ts">
+ import LinkButton from "$lib/components/LinkButton.svelte";
+ import MatchList from "$lib/components/matches/MatchList.svelte";
- if (!response.ok) {
- throw Error(response.statusText);
- }
+ export let matches;
+ export let hasNext;
- let matchData = await response.json();
- return matchData;
- }
+ $: viewMoreUrl = olderMatchesLink(matches);
- async function getMatchLog(matchId: string) {
- const matchData = await getMatchData(matchId);
- console.log(matchData);
- if (matchData["state"] !== "Finished") {
- // log is not available yet
+ // TODO: deduplicate.
+ // Maybe move to ApiClient logic?
+ function olderMatchesLink(matches: object[]): string {
+ if (matches.length == 0 || !hasNext) {
return null;
}
-
- const res = await fetch(`/api/matches/${matchId}/log`, {
- headers: {
- "Content-Type": "application/json",
- },
- });
-
- let log = await res.text();
- return log;
- }
-
- function setViewMode(viewMode_: ViewMode) {
- selectedMatchId = undefined;
- selectedMatchLog = undefined;
- viewMode = viewMode_;
- }
-
- function selectRules() {
- selectedMatchId = undefined;
- selectedMatchLog = undefined;
- viewMode = ViewMode.Rules;
- }
-
- function formatMatchTimestamp(timestampString: string): string {
- let timestamp = DateTime.fromISO(timestampString, { zone: "utc" }).toLocal();
- if (timestamp.startOf("day").equals(DateTime.now().startOf("day"))) {
- return timestamp.toFormat("HH:mm");
- } else {
- return timestamp.toFormat("dd/MM");
- }
+ const lastTimestamp = matches[matches.length - 1]["timestamp"];
+ return `/matches?before=${lastTimestamp}`;
}
-
- $: selectedMatch = matches.find((m) => m["id"] === selectedMatchId);
</script>
<div class="container">
- <div class="sidebar-left">
- <div
- class="editor-button sidebar-item"
- class:selected={viewMode === ViewMode.Editor}
- on:click={() => setViewMode(ViewMode.Editor)}
- >
- Editor
- </div>
- <div
- class="rules-button sidebar-item"
- class:selected={viewMode === ViewMode.Rules}
- on:click={() => setViewMode(ViewMode.Rules)}
- >
- Rules
- </div>
- <div
- class="sidebar-item"
- class:selected={viewMode === ViewMode.Leaderboard}
- on:click={() => setViewMode(ViewMode.Leaderboard)}
- >
- Leaderboard
- </div>
- <div class="sidebar-header">match history</div>
- <ul class="match-list">
- {#each matches as match}
- <li
- class="match-card sidebar-item"
- on:click={() => selectMatch(match.id)}
- class:selected={match.id === selectedMatchId}
- >
- <span class="match-timestamp">{formatMatchTimestamp(match.timestamp)}</span>
- <!-- hex is hardcoded for now, don't show map name -->
- <!-- <span class="match-mapname">hex</span> -->
- <!-- ugly temporary hardcode -->
- <span class="match-opponent">{match["players"][1]["bot_name"]}</span>
- </li>
- {/each}
- </ul>
+ <div class="introduction">
+ <h2>Welcome to PlanetWars!</h2>
+ <p>
+ Planetwars is a game of galactic conquest for busy people. Your goal is to program a bot that
+ will conquer the galaxy for you, while you take care of more important stuff.
+ </p>
+ <p>
+ Feel free to watch some games below to see what it's all about. When you are ready to try
+ writing your own bot, head over to
+ <a href="/docs">How to play</a> for instructions. You can program your bot in the browser
+ using the <a href="/editor">Editor</a>.
+ </p>
</div>
- <div class="editor-container">
- {#if viewMode === ViewMode.MatchVisualizer}
- <Visualizer matchData={selectedMatch} matchLog={selectedMatchLog} />
- {:else if viewMode === ViewMode.Editor}
- <EditorView {editSession} />
- {:else if viewMode === ViewMode.Rules}
- <RulesView />
- {:else if viewMode === ViewMode.Leaderboard}
- <Leaderboard />
- {/if}
- </div>
- <div class="sidebar-right">
- {#if viewMode === ViewMode.MatchVisualizer}
- <OutputPane matchLog={selectedMatchLog} />
- {:else if viewMode === ViewMode.Editor}
- <SubmitPane {editSession} on:matchCreated={onMatchCreated} />
- {/if}
+ <h2>Recent matches</h2>
+ <MatchList {matches} />
+ <div class="see-more-container">
+ <LinkButton href={viewMoreUrl}>View more</LinkButton>
</div>
</div>
-<style lang="scss">
- @import "src/styles/variables.scss";
-
+<style scoped lang="scss">
.container {
- display: flex;
- flex-grow: 1;
- min-height: 0;
+ max-width: 800px;
+ margin: 0 auto;
}
- .sidebar-left {
- width: 240px;
- background-color: $bg-color;
- display: flex;
- flex-direction: column;
- }
- .sidebar-right {
- width: 400px;
- background-color: white;
- border-left: 1px solid;
- padding: 0;
- display: flex;
- overflow: hidden;
- }
- .editor-container {
- flex-grow: 1;
- flex-shrink: 1;
- overflow: hidden;
- background-color: white;
- }
-
- .editor-container {
- height: 100%;
- }
-
- .sidebar-item {
- color: #eee;
- padding: 15px;
- }
-
- .sidebar-item:hover {
- background-color: #333;
- }
-
- .sidebar-item.selected {
- background-color: #333;
- }
-
- .match-list {
- list-style: none;
- color: #eee;
- padding-top: 15px;
- overflow-y: scroll;
- padding-left: 0px;
- }
-
- .match-card {
- padding: 10px 15px;
- font-size: 11pt;
- }
-
- .match-timestamp {
- color: #ccc;
- }
+ .introduction {
+ padding-top: 16px;
+ a {
+ color: rgb(9, 105, 218);
+ text-decoration: none;
+ }
- .match-opponent {
- padding: 0 0.5em;
+ a:hover {
+ text-decoration: underline;
+ }
}
- .sidebar-header {
- margin-top: 2em;
- text-transform: uppercase;
- font-weight: 600;
- color: rgba(255, 255, 255, 0.7);
- font-size: 14px;
- font-family: "Open Sans", sans-serif;
- padding-left: 14px;
+ .see-more-container {
+ padding: 24px;
+ text-align: center;
}
</style>
diff --git a/web/pw-server/src/routes/leaderboard.svelte b/web/pw-server/src/routes/leaderboard.svelte
new file mode 100644
index 0000000..7c4da6e
--- /dev/null
+++ b/web/pw-server/src/routes/leaderboard.svelte
@@ -0,0 +1,28 @@
+<script lang="ts" context="module">
+ import { ApiClient } from "$lib/api_client";
+
+ export async function load({ fetch }) {
+ try {
+ const apiClient = new ApiClient(fetch);
+ const leaderboard = await apiClient.get("/api/leaderboard");
+ return {
+ props: {
+ leaderboard,
+ },
+ };
+ } catch (error) {
+ return {
+ status: error.status,
+ error: error,
+ };
+ }
+ }
+</script>
+
+<script lang="ts">
+ import Leaderboard from "$lib/components/Leaderboard.svelte";
+
+ export let leaderboard: object[];
+</script>
+
+<Leaderboard {leaderboard} />
diff --git a/web/pw-server/src/routes/matches/[match_id].svelte b/web/pw-server/src/routes/matches/[match_id].svelte
index 2c0a3fa..7c1507c 100644
--- a/web/pw-server/src/routes/matches/[match_id].svelte
+++ b/web/pw-server/src/routes/matches/[match_id].svelte
@@ -1,33 +1,25 @@
<script lang="ts" context="module">
- function fetchJson(url: string): Promise<Response> {
- return fetch(url, {
- headers: {
- "Content-Type": "application/json",
- },
- });
- }
-
- export async function load({ params }) {
- // TODO: handle failure cases better
- const matchId = params["match_id"];
- const matchDataResponse = await fetchJson(`/api/matches/${matchId}`);
- if (!matchDataResponse.ok) {
- }
- const matchLogResponse = await fetchJson(`/api/matches/${matchId}/log`);
-
- if (matchDataResponse.ok && matchLogResponse.ok) {
+ import { ApiClient } from "$lib/api_client";
+ export async function load({ params, fetch }) {
+ try {
+ const matchId = params["match_id"];
+ const apiClient = new ApiClient(fetch);
+ const [matchData, matchLog] = await Promise.all([
+ apiClient.get(`/api/matches/${matchId}`),
+ apiClient.getText(`/api/matches/${matchId}/log`),
+ ]);
return {
props: {
- matchData: await matchDataResponse.json(),
- matchLog: await matchLogResponse.text(),
+ matchData: matchData,
+ matchLog: matchLog,
},
};
+ } catch (error) {
+ return {
+ status: error.status,
+ error: error,
+ };
}
-
- return {
- status: matchDataResponse.status,
- error: new Error("failed to load match"),
- };
}
</script>
diff --git a/web/pw-server/src/routes/matches/index.svelte b/web/pw-server/src/routes/matches/index.svelte
index 448048b..8c106fa 100644
--- a/web/pw-server/src/routes/matches/index.svelte
+++ b/web/pw-server/src/routes/matches/index.svelte
@@ -1,36 +1,121 @@
<script lang="ts" context="module">
- export async function load() {
- const res = await fetch("/api/matches", {
- headers: {
- "Content-Type": "application/json",
- },
- });
+ import { ApiClient } from "$lib/api_client";
+
+ const PAGE_SIZE = "50";
+
+ export async function load({ url, fetch }) {
+ try {
+ const apiClient = new ApiClient(fetch);
+ const botName = url.searchParams.get("bot");
+
+ let query = {
+ count: PAGE_SIZE,
+ before: url.searchParams.get("before"),
+ after: url.searchParams.get("after"),
+ bot: botName,
+ };
+
+ let { matches, has_next } = await apiClient.get("/api/matches", removeUndefined(query));
+
+ // TODO: should this be done client-side?
+ if (query["after"]) {
+ matches = matches.reverse();
+ }
- if (res.ok) {
return {
props: {
- matches: await res.json(),
+ matches,
+ botName,
+ hasNext: has_next,
+ query,
},
};
+ } catch (error) {
+ return {
+ status: error.status,
+ error: new Error("failed to load matches"),
+ };
}
+ }
- return {
- status: res.status,
- error: new Error("failed to load matches"),
- };
+ function removeUndefined(obj: Record<string, string>): Record<string, string> {
+ Object.keys(obj).forEach((key) => {
+ if (obj[key] === undefined || obj[key] === null) {
+ delete obj[key];
+ }
+ });
+ return obj;
}
</script>
<script lang="ts">
- import dayjs from "dayjs";
- export let matches;
+ import LinkButton from "$lib/components/LinkButton.svelte";
+ import MatchList from "$lib/components/matches/MatchList.svelte";
+
+ export let matches: object[];
+ export let botName: string | null;
+ // whether a next page exists in the current iteration direction (before/after)
+ export let hasNext: boolean;
+ export let query: object;
+
+ type Cursor = {
+ before?: string;
+ after?: string;
+ };
+
+ function pageLink(cursor: Cursor) {
+ let paramsObj = {
+ ...cursor,
+ };
+ if (botName) {
+ paramsObj["bot"] = botName;
+ }
+ const params = new URLSearchParams(paramsObj);
+ return `?${params}`;
+ }
+
+ function olderMatchesLink(matches: object[]): string {
+ if (matches.length == 0 || (query["before"] && !hasNext)) {
+ return null;
+ }
+ const lastTimestamp = matches[matches.length - 1]["timestamp"];
+ return pageLink({ before: lastTimestamp });
+ }
+
+ function newerMatchesLink(matches: object[]): string {
+ if (
+ matches.length == 0 ||
+ (query["after"] && !hasNext) ||
+ // we are viewing the first page, so there should be no newer matches.
+ // alternatively, we could show a "refresh" here.
+ (!query["before"] && !query["after"])
+ ) {
+ return null;
+ }
+ const firstTimestamp = matches[0]["timestamp"];
+ return pageLink({ after: firstTimestamp });
+ }
</script>
-<a href="/matches/new">new match</a>
-<ul>
- {#each matches as match}
- <li>
- <a href="/matches/{match['id']}">{dayjs(match["created_at"]).format("YYYY-MM-DD HH:mm")}</a>
- </li>
- {/each}
-</ul>
+<div class="container">
+ <MatchList {matches} />
+ <div class="page-controls">
+ <div class="btn-group">
+ <LinkButton href={newerMatchesLink(matches)}>Newer</LinkButton>
+ <LinkButton href={olderMatchesLink(matches)}>Older</LinkButton>
+ </div>
+ </div>
+</div>
+
+<style lang="scss">
+ .container {
+ width: 800px;
+ margin: 0 auto;
+ }
+
+ .page-controls {
+ display: flex;
+ justify-content: center;
+ margin: 24px 0;
+ }
+</style>
diff --git a/web/pw-server/src/routes/style.css b/web/pw-server/src/routes/style.css
index f7d5388..fa72c7e 100644
--- a/web/pw-server/src/routes/style.css
+++ b/web/pw-server/src/routes/style.css
@@ -2,3 +2,17 @@ body {
margin: 0;
font-family: Roboto, Helvetica, sans-serif;
}
+
+/* generic scrollbar styling for chrome & friends */
+::-webkit-scrollbar {
+ width: 5px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #bdbdbd;
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #6e6e6e;
+}