diff options
Diffstat (limited to 'web/pw-server/src/routes')
-rw-r--r-- | web/pw-server/src/routes/__layout.svelte | 49 | ||||
-rw-r--r-- | web/pw-server/src/routes/bots/[bot_name].svelte | 56 | ||||
-rw-r--r-- | web/pw-server/src/routes/bots/new.svelte | 1 | ||||
-rw-r--r-- | web/pw-server/src/routes/docs.svelte | 14 | ||||
-rw-r--r-- | web/pw-server/src/routes/editor.svelte | 255 | ||||
-rw-r--r-- | web/pw-server/src/routes/index.svelte | 321 | ||||
-rw-r--r-- | web/pw-server/src/routes/leaderboard.svelte | 28 | ||||
-rw-r--r-- | web/pw-server/src/routes/matches/[match_id].svelte | 40 | ||||
-rw-r--r-- | web/pw-server/src/routes/matches/index.svelte | 129 | ||||
-rw-r--r-- | web/pw-server/src/routes/style.css | 14 |
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; +} |