diff --git a/.gitignore b/.gitignore
index 1e7ebef..1904770 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,5 @@ frontend/package-lock*
frontend/package.json.md5
cover.out
.DS_Store
+.vscode/*
coverage.out
diff --git a/frontend/src/components/ButtonList.tsx b/frontend/src/components/ButtonList.tsx
new file mode 100644
index 0000000..fec253d
--- /dev/null
+++ b/frontend/src/components/ButtonList.tsx
@@ -0,0 +1,33 @@
+export type ButtonData = {
+ id: string;
+ label: string;
+ URL: string;
+ action?: string;
+ disabled?: boolean;
+ title?: string;
+};
+
+type Props = {
+ data: ButtonData[];
+ onClick?: (item: ButtonData) => void;
+ className?: string;
+};
+
+export default function ButtonList({ data, onClick, className }: Props) {
+ return (
+
+ {data.map((item) => (
+
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/racetime_gg.tsx b/frontend/src/components/racetime_gg.tsx
new file mode 100644
index 0000000..ff04757
--- /dev/null
+++ b/frontend/src/components/racetime_gg.tsx
@@ -0,0 +1,691 @@
+import { Authorize, GetAccessToken, CheckTokens, GenTokens } from "../../wailsjs/go/racetime/WebRace";
+import ButtonList, { ButtonData } from "./ButtonList"
+
+const restUrl = "http://localhost:8000"
+const socketUrl = "ws://localhost:9999"
+
+// Get list of races to be displayed
+export async function RaceList() {
+ try {
+ const response = await fetch(restUrl+"/races/data");
+ const json = await response.json(); // parse JSON
+ return json
+ } catch (err) {
+ console.error(err);
+ }
+}
+
+//Race List Window
+export async function RaceListWindow(w: Window) {
+ // Get race list also need to get the X-Date-Exact header value
+ const json = await RaceList()
+
+ // Populate buttons with races
+ const DATA: ButtonData[] = [
+ ];
+
+ for (let index = 0; index < json.races.length; index++) {
+ const categoryName = json.races[index].category.name;
+ const URL = json.races[index].url;
+ const entrantCount = json.races[index].entrants_count;
+ const entrantFinishedCount = json.races[index].entrants_count_finished;
+ const goal = json.races[index].goal.name;
+ const status = json.races[index].status.value;
+ // time stamp format 2025-12-06T08:18:13.788Z
+ const startedAt = json.races[index].started_at;
+ console.log(categoryName);
+ console.log(URL);
+ console.log(entrantCount);
+ console.log(entrantFinishedCount);
+ console.log(goal);
+ console.log(status);
+ console.log(startedAt);
+
+ // TODO: this should be saved from the racelist call
+ const x_date_exact_header: Date = new Date("2025-12-06T23:01:07Z");
+ var elapsedTime: Date = new Date(x_date_exact_header.getTime() - startedAt.getTime())
+ var runTime = status == 'in_progress' ? elapsedTime : "0"
+ DATA.push({
+ id: index.toString(),
+ URL: URL,
+ label: "[" + runTime + "] " + categoryName + " - " + goal + " (" + entrantFinishedCount + "/" + entrantCount + " Finished)"
+ });
+ }
+
+ {
+ console.log("Clicked", item);
+ RaceWindow(w, item.URL)
+ }}
+ />
+}
+
+export type messageData = {
+ message: string;
+ pinned?: boolean;
+ actions?: any;
+ direct_to?: string;
+ guid?: string;
+};
+
+//Race Window
+export async function RaceWindow(w: Window, dataURL: string) {
+ // variables
+ var goal
+ var info
+ let entrants: any[] = []
+ var category
+ var raceID
+ var joined = false
+ var forfeit = false
+ var done = false
+ // const tempURL = dataURL.split("/")
+ const authenticatedRaceURL = "/ws/o/race/"+dataURL.split("/")[1]
+ const accessToken = await GetAccessToken()
+
+
+ // open websocket for selected race
+ // websocket_oauth_url used for authenticated chat messages and real-time updates
+ // Example socket url "websocket_oauth_url": "/ws/o/race/funky-link-3070"
+ const ws = new WebSocket(socketUrl+authenticatedRaceURL+"?token="+accessToken);
+
+ ws.onopen = () => {
+ console.log("Connected to WebSocket server");
+
+ // Start ping interval
+ setInterval(() => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: "ping" }));
+ console.log("Ping sent");
+ }
+ }, 10_000);
+ };
+
+ // Listen for messages
+ ws.addEventListener("message", (event) => {
+ console.log("Message from server:", event.data);
+
+ const paragraph = w.document.getElementById('text') as HTMLParagraphElement
+ switch (event.data.type) {
+ case "chat.history":
+ // {
+ // "type": "chat.history",
+ // "messages": [
+ // {"id":"xa2wrRW32bl48fJq", ...},
+ // {"id":"g6Kem5bewJfG3ds2", ...},
+ // ]
+ // }
+ if (paragraph) {
+ for (let index = 0; index < event.data.messages.length; index++) {
+ paragraph.textContent += event.data.messages[index]
+ }
+ }
+ break;
+
+ case "chat.message":
+ // {
+ // "type": "chat.message",
+ // "message": {
+ // "id": "",
+ // "user": { },
+ // "bot": "",
+ // "direct_to": { },
+ // "posted_at": ""
+ // "message": "",
+ // "message_plain": "",
+ // "highlight": ,
+ // "is_dm": ,
+ // "is_bot": ,
+ // "is_system": ,
+ // "is_pinned": ,
+ // "delay": "",
+ // "actions" { }
+ // }
+ // }
+ // Get the hours (0-23)
+ const hours: number = event.data.message.posted_at.getHours();
+ // Get the minutes (0-59)
+ const minutes: number = event.data.message.posted_at.getMinutes();
+ // You can then format them as needed, for example, with leading zeros
+ const formattedHours: string = String(hours).padStart(2, '0');
+ const formattedMinutes: string = String(minutes).padStart(2, '0');
+
+ console.log(`Hours: ${hours}`);
+ console.log(`Minutes: ${minutes}`);
+ console.log(`Formatted Time: ${formattedHours}:${formattedMinutes}`);
+
+ if (paragraph) {
+ paragraph.textContent += formattedHours + ":" + formattedMinutes + " " + event.data.message.user.name + event.data.message.message
+ }
+ break
+
+ case "chat.dm":
+ // {
+ // "type": "chat.dm",
+ // "message": "",
+ // "from_user": { },
+ // "from_bot": "",
+ // "to": { },
+ // }
+ break
+
+ case "chat.pin":
+ // {
+ // "type": "chat.pin",
+ // "message": { ... }
+ // }
+ break
+
+ case "chat.unpin":
+ // {
+ // "type": "chat.pin",
+ // "message": { ... }
+ // }
+ break
+
+ case "chat.delete":
+ // chat.delete
+ // {
+ // "type": "chat.delete",
+ // "delete": {
+ // "id": "",
+ // "user": { },
+ // "bot": "",
+ // "is_bot": ,
+ // "deleted_by": { }
+ // }
+ // }
+ break
+
+ case "chat.purge":
+ // {
+ // "type": "chat.purge",
+ // "purge": {
+ // "user": { },
+ // "purged_by": { }
+ // }
+ // }
+ break
+
+ case "error":
+ // {
+ // "type": "error",
+ // "errors": [
+ // "Permission denied, you may need to re-authorise this application.",
+ // "..."
+ // ]
+ // }
+ console.log(event.data.errors)
+ break
+
+ case "pong":
+ // {
+ // "type": "pong"
+ // }
+ console.log("Pong received");
+ break
+
+ case "race.data":
+ // {
+ // "type": "race.data",
+ // "race": {
+ // ...
+ // }
+ // }
+ goal = event.data.race.goal.name
+ info = event.data.race.info
+ entrants = event.data.race.entrants
+ category = event.data.race.category.name
+ raceID = event.data.race.slug
+
+ const enter = w.document.getElementById('enterRaceButton') as HTMLButtonElement
+ const finish = w.document.getElementById('finishButton') as HTMLButtonElement
+ const forfeit = w.document.getElementById('forfeitButton') as HTMLButtonElement
+
+ // type RaceState
+ // invitational
+ // pending
+ // partitioned //(only for ladder 1v1 races)
+ // open
+ // in_progress
+ // finished
+ // cancelled
+ switch (event.data.race.status) {
+ case "open":
+ enterRaceButton.hidden = false
+ enterRaceButton.disabled = false
+ finishButton.hidden = true
+ finishButton.disabled = true
+ forfeitButton.hidden = true
+ forfeitButton.disabled = true
+ break
+
+ case "in_progress":
+ enterRaceButton.hidden = true
+ enterRaceButton.disabled = true
+ finishButton.hidden = false
+ finishButton.disabled = false
+ forfeitButton.hidden = false
+ forfeitButton.disabled = false
+ break
+
+ case "finished":
+ case "cancelled":
+ enterRaceButton.hidden = true
+ enterRaceButton.disabled = true
+ finishButton.hidden = true
+ finishButton.disabled = true
+ forfeitButton.hidden = true
+ forfeitButton.disabled = true
+ break
+ }
+
+ // update entrants list
+ break
+
+ default:
+ break;
+ }
+ });
+
+ // Handle connection close
+ ws.addEventListener("close", () => {
+ console.log("WebSocket connection closed");
+ });
+
+ // Handle errors
+ ws.addEventListener("error", (err) => {
+ console.error("WebSocket error:", err);
+ });
+
+ // clear window contents
+ w.document.body.innerHTML = "";
+
+ // title format
+ // {goal} [{category}] - {URL}
+ w.document.title = goal + " [" + category + "] - " + raceID
+
+ // top of window
+ // Goal: {goal} Info: {info}
+ const raceInfoBar: HTMLDivElement = w.document.createElement('div')
+ raceInfoBar.textContent = "Goal: " + goal + " Info: " + info
+ raceInfoBar.classList.add('race-info')
+ w.document.body.appendChild(raceInfoBar)
+
+ // right side of window
+ // List of entrants with stream status (color coded icon??), ready status (color code name??)
+
+ // type UserStatus
+ // ready
+ // not_ready
+ // in_progress
+ // done
+ // dnf //(did not finish, i.e. forfeited)
+ // dq //(disqualified)
+
+ // type UserRole
+ // const (
+ // Unknown UserRole = iota
+ // Anonymous
+ // Regular
+ // ChannelCreator UserRole = 4
+ // Monitor UserRole = 8
+ // Moderator UserRole = 16
+ // Staff UserRole = 32
+ // Bot UserRole = 64
+ // System UserRole = 128
+ const entrantList: HTMLUListElement = document.createElement('ul');
+ for (let index = 0; index < entrants.length; index++) {
+ const element = entrants[index].name;
+
+ const listItem: HTMLLIElement = document.createElement('li');
+ listItem.textContent = element;
+ entrantList.appendChild(listItem);
+ }
+
+ w.document.body.appendChild(entrantList);
+
+ // chat display window
+ const text = w.document.createElement('p')
+ text.id = 'text'
+ text.textContent = "";
+ w.document.body.appendChild(text)
+
+ // bottom of window
+ // [hide results checkbox] [Save Log button] [Ready checkbox] [Enter Race button]
+ // Create the hide results input element
+ const hideResultsCheckBox = w.document.createElement('input');
+ hideResultsCheckBox.type = 'checkbox';
+ hideResultsCheckBox.id = 'hideResults';
+ hideResultsCheckBox.name = 'hideResults';
+ hideResultsCheckBox.checked = false;
+
+ // Create the hide label element
+ const hideResultsLabel = w.document.createElement('label');
+ hideResultsLabel.htmlFor = 'hideResults'; // Associate label with the checkbox
+ hideResultsLabel.textContent = 'Hide Results';
+ hideResultsCheckBox.addEventListener('change', (event: Event) => {
+ const target = event.target as HTMLInputElement
+ if (ws.readyState === WebSocket.OPEN) {
+ if (target.checked) {
+ console.log('Checkbox is checked')
+
+ // TODO: hide entrants
+ } else {
+ console.log('Checkbox is unchecked')
+ // TODO: show entrants
+ }
+ } else {
+ console.warn("WebSocket is not open, message not sent.");
+ }
+ })
+
+ // Create a new button element
+ const saveChatLogButton: HTMLButtonElement = document.createElement('button');
+ saveChatLogButton.textContent = 'Save Chat Log!';
+ saveChatLogButton.id = 'saveChatLogButton';
+ saveChatLogButton.classList.add('save-chat-log');
+
+ // Set the type attribute (important for form submission behavior)
+ saveChatLogButton.type = 'button'; // or 'submit', 'reset'
+ saveChatLogButton.addEventListener('click', () => {
+ console.log('Save chat log clicked!');
+ // TODO: output chat log to file here
+ });
+
+ // Create the ready checkbox input element
+ const readyCheckBox = w.document.createElement('input');
+ readyCheckBox.type = 'checkbox';
+ readyCheckBox.id = 'ready';
+ readyCheckBox.name = 'ready';
+ readyCheckBox.checked = false;
+ readyCheckBox.addEventListener('change', (event: Event) => {
+ const target = event.target as HTMLInputElement
+ if (ws.readyState === WebSocket.OPEN) {
+ if (target.checked) {
+ console.log('Checkbox is checked')
+ // message format
+ // {
+ // "action": "message",
+ // "data": {
+ // "message": "Your message goes here",
+ // // "pinned": ,
+ // "actions":