almost finished matches, streamsync, some popups, and docs todo
This commit is contained in:
parent
02c03e3751
commit
7437911e9e
36 changed files with 4354 additions and 242 deletions
116
package-lock.json
generated
116
package-lock.json
generated
|
|
@ -14,17 +14,21 @@
|
|||
"@tauri-apps/plugin-shell": ">=2.0.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"d3": "^7.9.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"fs": "^0.0.1-security",
|
||||
"marked": "^14.1.4",
|
||||
"moons-ta-client": "^1.1.14",
|
||||
"pako": "^2.1.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"uuid": "^11.1.0"
|
||||
"uuid": "^11.1.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.1",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@tauri-apps/cli": ">=2.0.0",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/node": "^22.7.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
|
|
@ -1578,6 +1582,12 @@
|
|||
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/file-saver": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz",
|
||||
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz",
|
||||
|
|
@ -1618,6 +1628,14 @@
|
|||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
||||
|
|
@ -1847,6 +1865,18 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
|
|
@ -1885,6 +1915,14 @@
|
|||
"periscopic": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
|
|
@ -1934,6 +1972,17 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -2635,6 +2684,11 @@
|
|||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/file-saver": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
|
||||
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
|
|
@ -2664,6 +2718,14 @@
|
|||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
|
|
@ -6207,6 +6269,11 @@
|
|||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
|
|
@ -6724,6 +6791,17 @@
|
|||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
|
|
@ -7339,6 +7417,22 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
|
|
@ -7458,6 +7552,26 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz",
|
||||
|
|
|
|||
|
|
@ -18,17 +18,21 @@
|
|||
"@tauri-apps/plugin-shell": ">=2.0.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"d3": "^7.9.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"fs": "^0.0.1-security",
|
||||
"marked": "^14.1.4",
|
||||
"moons-ta-client": "^1.1.14",
|
||||
"pako": "^2.1.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"uuid": "^11.1.0"
|
||||
"uuid": "^11.1.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.1",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@tauri-apps/cli": ">=2.0.0",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/node": "^22.7.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
|
|
|
|||
600
src/lib/components/modules/PreviouslyPlayedSongs.svelte
Normal file
600
src/lib/components/modules/PreviouslyPlayedSongs.svelte
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { type Map, GameplayModifiers_GameOptions, Push_SongFinished, RealtimeScore } from 'moons-ta-client';
|
||||
import type { BeatSaverMap } from '$lib/services/beatsaver.js';
|
||||
|
||||
interface ScoreWithAccuracy extends Push_SongFinished {
|
||||
accuracy: number;
|
||||
}
|
||||
|
||||
interface PreviousResults {
|
||||
taData: Map;
|
||||
beatsaverData: BeatSaverMap;
|
||||
scores: ScoreWithAccuracy[];
|
||||
completionType: 'Completed' | 'Still Awaiting Scores' | 'Exited To Menu';
|
||||
}
|
||||
|
||||
export let maps: PreviousResults[] = [];
|
||||
|
||||
$: maps = maps.map(map => {
|
||||
const newScores = map.scores.map(score => {
|
||||
const accuracy = calculateAccuracy(score, map);
|
||||
return {
|
||||
...score,
|
||||
accuracy: parseFloat(accuracy)
|
||||
} as ScoreWithAccuracy;
|
||||
});
|
||||
map.scores = newScores;
|
||||
return map;
|
||||
});
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
enum MapDifficulty {
|
||||
"Easy" = 0,
|
||||
"Normal" = 1,
|
||||
"Hard" = 2,
|
||||
"Expert" = 3,
|
||||
"ExpertPlus" = 4,
|
||||
}
|
||||
|
||||
const modifierNameMap: Record<number, string> = {
|
||||
[GameplayModifiers_GameOptions.None]: "",
|
||||
[GameplayModifiers_GameOptions.NoFail]: "No Fail",
|
||||
[GameplayModifiers_GameOptions.NoBombs]: "No Bombs",
|
||||
[GameplayModifiers_GameOptions.NoArrows]: "No Arrows",
|
||||
[GameplayModifiers_GameOptions.NoObstacles]: "No Walls",
|
||||
[GameplayModifiers_GameOptions.SlowSong]: "Slower Song",
|
||||
[GameplayModifiers_GameOptions.InstaFail]: "One Life",
|
||||
[GameplayModifiers_GameOptions.FailOnClash]: "Fail on Clash",
|
||||
[GameplayModifiers_GameOptions.BatteryEnergy]: "Four Lives",
|
||||
[GameplayModifiers_GameOptions.FastNotes]: "Fast Notes",
|
||||
[GameplayModifiers_GameOptions.FastSong]: "Faster Song",
|
||||
[GameplayModifiers_GameOptions.DisappearingArrows]: "Disappearing Arrows",
|
||||
[GameplayModifiers_GameOptions.GhostNotes]: "Ghost Notes",
|
||||
[GameplayModifiers_GameOptions.DemoNoFail]: "Demo No Fail",
|
||||
[GameplayModifiers_GameOptions.DemoNoObstacles]: "Demo No Obstacles",
|
||||
[GameplayModifiers_GameOptions.StrictAngles]: "Strict Angles",
|
||||
[GameplayModifiers_GameOptions.ProMode]: "Pro Mode",
|
||||
[GameplayModifiers_GameOptions.ZenMode]: "Zen Mode",
|
||||
[GameplayModifiers_GameOptions.SmallCubes]: "Small Notes",
|
||||
[GameplayModifiers_GameOptions.SuperFastSong]: "Super Fast Song"
|
||||
};
|
||||
|
||||
function handleRemoveFromHistory(map: PreviousResults) {
|
||||
dispatch('removeFromHistory', { map });
|
||||
}
|
||||
|
||||
function handleAddBackToQueue(map: PreviousResults) {
|
||||
dispatch('addBackToQueue', { map });
|
||||
}
|
||||
|
||||
function handleCopyScores(map: PreviousResults) {
|
||||
const mapName = map.beatsaverData.name || 'Unknown Song';
|
||||
const artist = map.beatsaverData.metadata?.songAuthorName || 'Unknown Artist';
|
||||
const difficulty = getDifficultyName(map.taData.gameplayParameters?.beatmap?.difficulty || 0);
|
||||
|
||||
let discordMessage = `**${mapName}** by ${artist}\n`;
|
||||
discordMessage += `**Difficulty:** ${difficulty}\n\n`;
|
||||
|
||||
// Sort scores by total score (descending)
|
||||
const sortedScores = [...map.scores].sort((a, b) => b.score - a.score);
|
||||
|
||||
sortedScores.forEach((score, index) => {
|
||||
const position = index + 1;
|
||||
const positionText = position === 1 ? '1st' : position === 2 ? '2nd' : position === 3 ? '3rd' : `${position}th`;
|
||||
const displayName = score.player?.name || score.player?.discordInfo?.username;
|
||||
|
||||
discordMessage += `**${positionText} ${displayName}**\n`;
|
||||
discordMessage += `Score: ${score.score.toLocaleString()}\n`;
|
||||
discordMessage += `Accuracy: ${score.accuracy.toFixed(2)}%\n`;
|
||||
discordMessage += `Misses: ${score.misses} | Bad Cuts: ${score.badCuts}\n\n`;
|
||||
});
|
||||
|
||||
navigator.clipboard.writeText(discordMessage).then(() => {
|
||||
console.log('Scores copied to clipboard');
|
||||
});
|
||||
}
|
||||
|
||||
function handleShowDetails(map: PreviousResults) {
|
||||
dispatch('showMapDetails', { map });
|
||||
}
|
||||
|
||||
function getActiveModifiersCompact(gameplayModifiers: number): string[] {
|
||||
const activeModifiers: string[] = [];
|
||||
const values = Object.values(GameplayModifiers_GameOptions)
|
||||
.filter((value): value is number =>
|
||||
typeof value === 'number' &&
|
||||
value !== GameplayModifiers_GameOptions.None &&
|
||||
value > 0
|
||||
)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
for (const value of values) {
|
||||
if ((gameplayModifiers & value) === value) {
|
||||
const name = modifierNameMap[value];
|
||||
if (name && name.trim() !== "") {
|
||||
activeModifiers.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return activeModifiers;
|
||||
}
|
||||
|
||||
function getModifierArray(map: PreviousResults): string[] {
|
||||
if (map.taData.gameplayParameters?.gameplayModifiers?.options == 0) {
|
||||
return [];
|
||||
}
|
||||
return getActiveModifiersCompact(map.taData.gameplayParameters?.gameplayModifiers?.options || 0);
|
||||
}
|
||||
|
||||
function getDifficultyName(difficulty: number): string {
|
||||
return MapDifficulty[difficulty] || 'Unknown';
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function getCompletionTypeClass(type: string): string {
|
||||
switch (type) {
|
||||
case 'Completed':
|
||||
return 'completion-completed';
|
||||
case 'Still Awaiting Scores':
|
||||
return 'completion-awaiting';
|
||||
case 'Exited To Menu':
|
||||
return 'completion-exited';
|
||||
default:
|
||||
return 'completion-unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function getCompletionTypeIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'Completed':
|
||||
return 'check_circle';
|
||||
case 'Still Awaiting Scores':
|
||||
return 'schedule';
|
||||
case 'Exited To Menu':
|
||||
return 'exit_to_app';
|
||||
default:
|
||||
return 'help';
|
||||
}
|
||||
}
|
||||
|
||||
function getMaxScore(songInfo: BeatSaverMap, characteristic: string = "standard", difficulty: string): number {
|
||||
const diff = maps.find(x => x.beatsaverData.versions[0].hash == songInfo.versions[0].hash)?.beatsaverData.versions[0].diffs.find(
|
||||
(x) => ((characteristic.toLowerCase() !== 'expertplus' && characteristic.toLowerCase() !== 'expert+') ? x.characteristic.toLowerCase() === characteristic.toLowerCase() : (x.characteristic.toLowerCase() == 'expertplus' || x.characteristic.toLowerCase() == 'expert+')) && x.difficulty.toLowerCase() === difficulty.toLowerCase()
|
||||
);
|
||||
|
||||
return diff?.maxScore ?? 0;
|
||||
}
|
||||
|
||||
// Method to convert difficulty number to string
|
||||
function getDifficultyAsString(difficulty: number): string {
|
||||
return MapDifficulty[difficulty] || "ExpertPlus";
|
||||
}
|
||||
|
||||
// Main accuracy calculation logic
|
||||
function calculateAccuracy(score: Push_SongFinished, mapWithSongInfo: PreviousResults): string {
|
||||
console.log(maps)
|
||||
const maxScore = getMaxScore(
|
||||
mapWithSongInfo.beatsaverData,
|
||||
score.beatmap?.characteristic?.serializedName ?? "Standard",
|
||||
getDifficultyAsString(score.beatmap?.difficulty ?? 4) || "ExpertPlus"
|
||||
);
|
||||
|
||||
const accuracy = ((score.score / maxScore) * 100).toFixed(2);
|
||||
return accuracy;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="previously-played">
|
||||
<div class="history-header">
|
||||
<h3>Previously Played ({maps.length})</h3>
|
||||
</div>
|
||||
|
||||
{#if maps.length === 0}
|
||||
<div class="empty-state">
|
||||
<span class="material-icons">history</span>
|
||||
<p>No maps played yet</p>
|
||||
<p class="empty-subtitle">Completed maps will appear here</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="maps-list">
|
||||
{#each maps as map (map.taData.guid)}
|
||||
<div class="map-card">
|
||||
<div class="map-cover">
|
||||
<img
|
||||
src={map.beatsaverData.versions[0]?.coverURL || '/default-song-cover.png'}
|
||||
alt="Map Cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="map-info">
|
||||
<h4 class="map-title">{map.beatsaverData.name || 'Unknown Song'}</h4>
|
||||
<p class="map-artist">{map.beatsaverData.metadata?.songAuthorName || 'Unknown Artist'}</p>
|
||||
<div class="map-details">
|
||||
<span class="difficulty-badge difficulty-{map.taData.gameplayParameters?.beatmap?.difficulty}">
|
||||
{getDifficultyName(map.taData.gameplayParameters?.beatmap?.difficulty || 0)}
|
||||
</span>
|
||||
<span class="completion-tag {getCompletionTypeClass(map.completionType)}">
|
||||
<span class="material-icons">{getCompletionTypeIcon(map.completionType)}</span>
|
||||
{map.completionType}
|
||||
</span>
|
||||
{#if getModifierArray(map).length > 0}
|
||||
<div class="modifiers">
|
||||
{#each getModifierArray(map) as modifier}
|
||||
<span class="modifier-tag">{modifier}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="score-summary">
|
||||
{#if map.scores.length > 0}
|
||||
{@const topScore = map.scores.reduce((max, score) => score.score > max.score ? score : max)}
|
||||
<span class="top-score">
|
||||
<span class="material-icons">emoji_events</span>
|
||||
{topScore.player?.name || topScore.player?.discordInfo?.username}: {Number(topScore.tournamentId).toLocaleString()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-actions">
|
||||
<button
|
||||
class="action-button details-button"
|
||||
on:click={() => handleShowDetails(map)}
|
||||
title="Show Details"
|
||||
>
|
||||
<span class="material-icons">info</span>
|
||||
</button>
|
||||
<button
|
||||
class="action-button copy-scores"
|
||||
on:click={() => handleCopyScores(map)}
|
||||
title="Copy Scores to Clipboard"
|
||||
>
|
||||
<span class="material-icons">content_copy</span>
|
||||
</button>
|
||||
<button
|
||||
class="action-button add-back"
|
||||
on:click={() => handleAddBackToQueue(map)}
|
||||
title="Add Back to Queue"
|
||||
>
|
||||
<span class="material-icons">add</span>
|
||||
</button>
|
||||
<button
|
||||
class="action-button remove-history"
|
||||
on:click={() => handleRemoveFromHistory(map)}
|
||||
title="Remove from History"
|
||||
>
|
||||
<span class="material-icons">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.previously-played {
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.history-header h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.action-button.details-button:hover {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
|
||||
.action-button.copy-scores:hover {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.action-button.add-back:hover {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
|
||||
.action-button.remove-history:hover {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-secondary);
|
||||
gap: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state .material-icons {
|
||||
font-size: 3rem;
|
||||
opacity: 0.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.empty-subtitle {
|
||||
font-size: 0.875rem !important;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.maps-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.map-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.map-card:hover {
|
||||
background-color: var(--bg-primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.map-cover {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.map-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.map-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.map-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.map-artist {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.map-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.difficulty-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.difficulty-0 { background-color: #10B981; color: white; } /* Easy - Green */
|
||||
.difficulty-1 { background-color: #3B82F6; color: white; } /* Normal - Blue */
|
||||
.difficulty-2 { background-color: #F59E0B; color: white; } /* Hard - Yellow */
|
||||
.difficulty-3 { background-color: #EF4444; color: white; } /* Expert - Red */
|
||||
.difficulty-4 { background-color: #8B5CF6; color: white; } /* Expert+ - Purple */
|
||||
|
||||
.played-time {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background-color: rgba(156, 163, 175, 0.1);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.completion-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.completion-completed {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: #10B981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.completion-awaiting {
|
||||
background-color: rgba(245, 158, 11, 0.1);
|
||||
color: #F59E0B;
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.completion-exited {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: #EF4444;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.completion-unknown {
|
||||
background-color: rgba(156, 163, 175, 0.1);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid rgba(156, 163, 175, 0.2);
|
||||
}
|
||||
|
||||
.completion-tag .material-icons {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.modifiers {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modifier-tag {
|
||||
padding: 0.125rem 0.375rem;
|
||||
background-color: rgba(79, 70, 229, 0.1);
|
||||
color: var(--accent-color);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid rgba(79, 70, 229, 0.2);
|
||||
}
|
||||
|
||||
.score-summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.top-score {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background-color: rgba(251, 191, 36, 0.1);
|
||||
color: #F59E0B;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.top-score .material-icons {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.map-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-family: 'Material Icons';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
.maps-list::-webkit-scrollbar {
|
||||
width: 0.375rem;
|
||||
}
|
||||
|
||||
.maps-list::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.maps-list::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.maps-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.map-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.map-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.map-title,
|
||||
.map-artist {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
}
|
||||
|
||||
.map-details {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.map-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
680
src/lib/components/modules/SongQueue.svelte
Normal file
680
src/lib/components/modules/SongQueue.svelte
Normal file
|
|
@ -0,0 +1,680 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { type Map, GameplayModifiers_GameOptions } from 'moons-ta-client';
|
||||
import type { BeatSaverMap } from '$lib/services/beatsaver.js';
|
||||
|
||||
interface CustomMap {
|
||||
taData: Map,
|
||||
beatsaverData: BeatSaverMap
|
||||
}
|
||||
|
||||
export let maps: CustomMap[] = [];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
enum MapDifficulty {
|
||||
"Easy" = 0,
|
||||
"Normal" = 1,
|
||||
"Hard" = 2,
|
||||
"Expert" = 3,
|
||||
"Expert+" = 4
|
||||
}
|
||||
|
||||
const modifierNameMap: Record<number, string> = {
|
||||
[GameplayModifiers_GameOptions.None]: "",
|
||||
[GameplayModifiers_GameOptions.NoFail]: "No Fail",
|
||||
[GameplayModifiers_GameOptions.NoBombs]: "No Bombs",
|
||||
[GameplayModifiers_GameOptions.NoArrows]: "No Arrows",
|
||||
[GameplayModifiers_GameOptions.NoObstacles]: "No Walls",
|
||||
[GameplayModifiers_GameOptions.SlowSong]: "Slower Song",
|
||||
[GameplayModifiers_GameOptions.InstaFail]: "One Life",
|
||||
[GameplayModifiers_GameOptions.FailOnClash]: "Fail on Clash",
|
||||
[GameplayModifiers_GameOptions.BatteryEnergy]: "Four Lives",
|
||||
[GameplayModifiers_GameOptions.FastNotes]: "Fast Notes",
|
||||
[GameplayModifiers_GameOptions.FastSong]: "Faster Song",
|
||||
[GameplayModifiers_GameOptions.DisappearingArrows]: "Disappearing Arrows",
|
||||
[GameplayModifiers_GameOptions.GhostNotes]: "Ghost Notes",
|
||||
[GameplayModifiers_GameOptions.DemoNoFail]: "Demo No Fail",
|
||||
[GameplayModifiers_GameOptions.DemoNoObstacles]: "Demo No Obstacles",
|
||||
[GameplayModifiers_GameOptions.StrictAngles]: "Strict Angles",
|
||||
[GameplayModifiers_GameOptions.ProMode]: "Pro Mode",
|
||||
[GameplayModifiers_GameOptions.ZenMode]: "Zen Mode",
|
||||
[GameplayModifiers_GameOptions.SmallCubes]: "Small Notes",
|
||||
[GameplayModifiers_GameOptions.SuperFastSong]: "Super Fast Song"
|
||||
};
|
||||
|
||||
let showConfirmDialog = false;
|
||||
let mapToLoad: CustomMap | null = null;
|
||||
|
||||
function handleAddMap() {
|
||||
dispatch('addMap');
|
||||
}
|
||||
|
||||
function handleEditMap(map: CustomMap) {
|
||||
dispatch('editMap', { map: map });
|
||||
}
|
||||
|
||||
function handleRemoveMap(map: CustomMap) {
|
||||
dispatch('removeMap', { map: map });
|
||||
}
|
||||
|
||||
function handleLoadMap(map: CustomMap) {
|
||||
mapToLoad = map;
|
||||
showConfirmDialog = true;
|
||||
}
|
||||
|
||||
function handleMapClick(map: CustomMap) {
|
||||
handleLoadMap(map);
|
||||
}
|
||||
|
||||
function confirmLoad() {
|
||||
if (mapToLoad) {
|
||||
dispatch('songLoad', { map: mapToLoad });
|
||||
showConfirmDialog = false;
|
||||
mapToLoad = null;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelLoad() {
|
||||
showConfirmDialog = false;
|
||||
mapToLoad = null;
|
||||
}
|
||||
|
||||
function getActiveModifiersCompact(gameplayModifiers: number): string[] {
|
||||
const activeModifiers: string[] = [];
|
||||
const values = Object.values(GameplayModifiers_GameOptions)
|
||||
.filter((value): value is number =>
|
||||
typeof value === 'number' &&
|
||||
value !== GameplayModifiers_GameOptions.None &&
|
||||
value > 0
|
||||
)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
for (const value of values) {
|
||||
if ((gameplayModifiers & value) === value) {
|
||||
const name = modifierNameMap[value];
|
||||
if (name && name.trim() !== "") {
|
||||
activeModifiers.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return activeModifiers;
|
||||
}
|
||||
|
||||
function getModifierArray(map: CustomMap): string[] {
|
||||
if (map.taData.gameplayParameters?.gameplayModifiers?.options == 0) {
|
||||
return [];
|
||||
}
|
||||
return getActiveModifiersCompact(map.taData.gameplayParameters?.gameplayModifiers?.options || 0);
|
||||
}
|
||||
|
||||
function getDifficultyName(difficulty: number): string {
|
||||
return MapDifficulty[difficulty] || 'Unknown';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="song-queue">
|
||||
<div class="queue-header">
|
||||
<h3>Song Queue ({maps.length})</h3>
|
||||
<button class="action-button add-map" on:click={handleAddMap}>
|
||||
<span class="material-icons">add</span>
|
||||
Add Map
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if maps.length === 0}
|
||||
<div class="empty-state">
|
||||
<span class="material-icons">queue_music</span>
|
||||
<p>No maps in queue</p>
|
||||
<p class="empty-subtitle">Add maps to get started</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="maps-list">
|
||||
{#each maps as map, index (map.taData.guid)}
|
||||
<div class="map-card" on:click={() => handleMapClick(map)} on:keydown={(e) => e.key === 'Enter' && handleMapClick(map)} tabindex="0" role="button">
|
||||
<div class="map-index">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div class="map-cover">
|
||||
<img
|
||||
src={map.beatsaverData.versions[0]?.coverURL || '/default-song-cover.png'}
|
||||
alt="Map Cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="map-info">
|
||||
<h4 class="map-title">{map.beatsaverData.name || 'Unknown Song'}</h4>
|
||||
<p class="map-artist">{map.beatsaverData.metadata?.songAuthorName || 'Unknown Artist'}</p>
|
||||
<div class="map-details">
|
||||
<span class="difficulty-badge difficulty-{map.taData.gameplayParameters?.beatmap?.difficulty}">
|
||||
{getDifficultyName(map.taData.gameplayParameters?.beatmap?.difficulty || 0)}
|
||||
</span>
|
||||
{#if getModifierArray(map).length > 0}
|
||||
<div class="modifiers">
|
||||
{#each getModifierArray(map) as modifier}
|
||||
<span class="modifier-tag">{modifier}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="map-actions" on:click|stopPropagation on:keydown|stopPropagation>
|
||||
<button
|
||||
class="action-button load-map"
|
||||
on:click={() => handleLoadMap(map)}
|
||||
title="Load Map"
|
||||
>
|
||||
<span class="material-icons">play_arrow</span>
|
||||
</button>
|
||||
<button
|
||||
class="action-button edit-map"
|
||||
on:click={() => handleEditMap(map)}
|
||||
title="Edit Map"
|
||||
>
|
||||
<span class="material-icons">edit</span>
|
||||
</button>
|
||||
<button
|
||||
class="action-button remove-map"
|
||||
on:click={() => handleRemoveMap(map)}
|
||||
title="Remove Map"
|
||||
>
|
||||
<span class="material-icons">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
{#if showConfirmDialog}
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="dialog-overlay" on:click={cancelLoad} on:keydown={(e) => e.key === 'Escape' && cancelLoad()}>
|
||||
<div class="dialog" on:click|stopPropagation on:keydown|stopPropagation>
|
||||
<div class="dialog-header">
|
||||
<h3>Load Map</h3>
|
||||
<button class="dialog-close" on:click={cancelLoad}>
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dialog-content">
|
||||
<p>Are you sure you want to load this map for all players in the match?</p>
|
||||
{#if mapToLoad}
|
||||
<div class="map-preview">
|
||||
<img
|
||||
src={mapToLoad.beatsaverData.versions[0]?.coverURL || '/default-song-cover.png'}
|
||||
alt="Map Cover"
|
||||
class="preview-cover"
|
||||
/>
|
||||
<div class="preview-info">
|
||||
<h4>{mapToLoad.beatsaverData.name || 'Unknown Song'}</h4>
|
||||
<p>{mapToLoad.beatsaverData.metadata?.songAuthorName || 'Unknown Artist'}</p>
|
||||
<span class="difficulty-badge difficulty-{mapToLoad.taData.gameplayParameters?.beatmap?.difficulty}">
|
||||
{getDifficultyName(mapToLoad.taData.gameplayParameters?.beatmap?.difficulty || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button class="action-button cancel-button" on:click={cancelLoad}>Cancel</button>
|
||||
<button class="action-button confirm-button" on:click={confirmLoad}><span class="material-icons">play_arrow</span> Load Map</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.song-queue {
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.queue-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.queue-header h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.action-button.add-map {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-button.add-map:hover {
|
||||
background-color: var(--accent-hover);
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
|
||||
.action-button.load-map {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
padding: 0.375rem;
|
||||
}
|
||||
|
||||
.action-button.load-map:hover {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
|
||||
.action-button.edit-map {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
padding: 0.375rem;
|
||||
}
|
||||
|
||||
.action-button.edit-map:hover {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.action-button.remove-map {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
padding: 0.375rem;
|
||||
}
|
||||
|
||||
.action-button.remove-map:hover {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-secondary);
|
||||
gap: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state .material-icons {
|
||||
font-size: 3rem;
|
||||
opacity: 0.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.empty-subtitle {
|
||||
font-size: 0.875rem !important;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.maps-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.map-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.map-card:hover {
|
||||
background-color: var(--bg-primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.map-card:focus {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.map-index {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.map-cover {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.map-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.map-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.map-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.map-artist {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.map-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.difficulty-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.difficulty-0 { background-color: #10B981; color: white; } /* Easy - Green */
|
||||
.difficulty-1 { background-color: #3B82F6; color: white; } /* Normal - Blue */
|
||||
.difficulty-2 { background-color: #F59E0B; color: white; } /* Hard - Yellow */
|
||||
.difficulty-3 { background-color: #EF4444; color: white; } /* Expert - Red */
|
||||
.difficulty-4 { background-color: #8B5CF6; color: white; } /* Expert+ - Purple */
|
||||
|
||||
.modifiers {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modifier-tag {
|
||||
padding: 0.125rem 0.375rem;
|
||||
background-color: rgba(79, 70, 229, 0.1);
|
||||
color: var(--accent-color);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid rgba(79, 70, 229, 0.2);
|
||||
}
|
||||
|
||||
.map-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Dialog Styles */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 0.75rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 1.5rem 0 1.5rem;
|
||||
}
|
||||
|
||||
.dialog-header h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dialog-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-close:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.dialog-content p {
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.map-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-cover {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 0.375rem;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-info h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.preview-info p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.confirm-button {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.confirm-button:hover {
|
||||
background-color: var(--accent-hover);
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-family: 'Material Icons';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
.maps-list::-webkit-scrollbar {
|
||||
width: 0.375rem;
|
||||
}
|
||||
|
||||
.maps-list::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.maps-list::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.maps-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.queue-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.map-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.map-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.map-title,
|
||||
.map-artist {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
}
|
||||
|
||||
.map-details {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: 95%;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.map-preview {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-info h4,
|
||||
.preview-info p {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,13 +2,12 @@
|
|||
import { createEventDispatcher } from "svelte";
|
||||
//@ts-ignore
|
||||
import { Response_ResponseType, Map, Characteristic, GameplayModifiers_GameOptions } from 'moons-ta-client';
|
||||
import { TAServerPort, TAServerUrl, authTokenStore, client } from "$lib/stores";
|
||||
import { TAServerPort, TAServerUrl, authTokenStore } from "$lib/stores";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { onMount } from "svelte";
|
||||
import type { BeatSaverMap } from "$lib/services/beatsaver";
|
||||
|
||||
export let tournamentGuid: string;
|
||||
export let poolGuid: string;
|
||||
export let mode: 'qualifier' | 'playing' = 'playing';
|
||||
export let map: {
|
||||
taData: Map,
|
||||
|
|
@ -17,12 +16,6 @@
|
|||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// Remove 'Bearer ' from the token
|
||||
if ($authTokenStore) {
|
||||
const cleanToken = $authTokenStore.replace('Bearer ', '');
|
||||
client.setAuthToken(cleanToken);
|
||||
}
|
||||
|
||||
// Form state
|
||||
let isLoading = false;
|
||||
let error: string | null = null;
|
||||
|
|
@ -407,7 +400,7 @@
|
|||
selectedCharacteristic = availableCharacteristics[0] || "";
|
||||
selectedDifficulty = availableDifficulties[selectedCharacteristic]?.[0] || "";
|
||||
|
||||
mapBeatmapId = `custom_level_${latestVersion.hash.toLocaleUpperCase()}`;
|
||||
mapBeatmapId = `custom_level_${latestVersion.hash.toUpperCase()}`;
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
|
|
@ -446,21 +439,6 @@
|
|||
error = null;
|
||||
|
||||
try {
|
||||
if(!client.isConnected) {
|
||||
const connectResult = await client.connect($TAServerUrl, $TAServerPort);
|
||||
|
||||
if (connectResult.details.oneofKind === "connect" && connectResult.type === Response_ResponseType.Fail) {
|
||||
throw new Error(connectResult.details.connect.message);
|
||||
}
|
||||
}
|
||||
|
||||
if(!client.stateManager.getTournament(tournamentGuid)!.users.some(user => user.guid == client.stateManager.getSelfGuid())) {
|
||||
const joinResult = await client.joinTournament(tournamentGuid);
|
||||
if (joinResult.details.oneofKind !== 'join' || joinResult.type === Response_ResponseType.Fail) {
|
||||
throw new Error('Could not join tournament');
|
||||
}
|
||||
}
|
||||
|
||||
const modifiersBitmap = getActiveModifiersAsBitmap();
|
||||
const mapData: Map = {
|
||||
guid: map?.taData.guid || uuidv4(),
|
||||
|
|
|
|||
835
src/lib/components/popups/QualifierLeaderboard.svelte
Normal file
835
src/lib/components/popups/QualifierLeaderboard.svelte
Normal file
|
|
@ -0,0 +1,835 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { QualifierEvent_LeaderboardSort, Map } from 'moons-ta-client';
|
||||
import type { BeatSaverMap } from '$lib/services/beatsaver';
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { saveAs } from 'file-saver';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
interface Score {
|
||||
accuracy: number;
|
||||
badCuts: number;
|
||||
color: string;
|
||||
eventId: string;
|
||||
fullCombo: boolean;
|
||||
goodCuts: number;
|
||||
isPlaceholder: boolean;
|
||||
mapId: string;
|
||||
maxCombo: number;
|
||||
maxPossibleScore: number;
|
||||
modifiedScore: number;
|
||||
multipliedScore: number;
|
||||
notesMissed: number;
|
||||
platformId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface CustomMap {
|
||||
taData: Map,
|
||||
beatsaverData: BeatSaverMap
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let scores: Score[] = [];
|
||||
export let mapData: CustomMap | null;
|
||||
export let sortType: QualifierEvent_LeaderboardSort = QualifierEvent_LeaderboardSort.ModifiedScore;
|
||||
|
||||
// Export type definition
|
||||
type ExportType = 'json' | 'csv' | 'excel' | 'txt';
|
||||
let selectedExportType: ExportType = 'json';
|
||||
let showExportDropdown = false;
|
||||
|
||||
let currentPage = 1;
|
||||
let itemsPerPage = 10;
|
||||
let searchQuery = "";
|
||||
|
||||
$: filteredScores = scores.filter(score =>
|
||||
score.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
score.platformId.includes(searchQuery)
|
||||
);
|
||||
|
||||
$: totalPages = Math.ceil(filteredScores.length / itemsPerPage);
|
||||
$: paginatedScores = filteredScores.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
$: startIndex = (currentPage - 1) * itemsPerPage;
|
||||
|
||||
function formatAccuracy(accuracy: number): string {
|
||||
return (accuracy * 100).toFixed(2) + '%';
|
||||
}
|
||||
|
||||
function formatScore(score: number): string {
|
||||
return score.toLocaleString();
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
currentPage = page;
|
||||
}
|
||||
}
|
||||
|
||||
function getPaginationPages(): number[] {
|
||||
const pages: number[] = [];
|
||||
const maxVisible = 5;
|
||||
|
||||
if (totalPages <= maxVisible) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
const half = Math.floor(maxVisible / 2);
|
||||
let start = Math.max(1, currentPage - half);
|
||||
let end = Math.min(totalPages, start + maxVisible - 1);
|
||||
|
||||
if (end - start + 1 < maxVisible) {
|
||||
start = Math.max(1, end - maxVisible + 1);
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
function formatLeaderboardData() {
|
||||
return scores.map(score => ({
|
||||
playerUsername: score.username,
|
||||
playerPlatformId: score.platformId,
|
||||
score: score.modifiedScore,
|
||||
accuracy: score.accuracy,
|
||||
misses: score.notesMissed,
|
||||
badCuts: score.badCuts,
|
||||
isFC: score.fullCombo,
|
||||
multipliedScore: score.multipliedScore,
|
||||
maxCombo: score.maxCombo,
|
||||
goodCuts: score.goodCuts,
|
||||
maxScore: score.maxPossibleScore
|
||||
}));
|
||||
}
|
||||
|
||||
// Export functions
|
||||
function exportJSON() {
|
||||
const data = formatLeaderboardData();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
saveAs(blob, `${mapData!.taData.gameplayParameters!.beatmap!.name}_qualifier_scores.json`);
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
const data = formatLeaderboardData();
|
||||
const headers = Object.keys(data[0]).join(',');
|
||||
const rows = data.map(x => Object.values(x).join(','));
|
||||
const csv = [headers, ...rows].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
saveAs(blob, `${mapData!.taData.gameplayParameters!.beatmap!.name}_qualifier_scores.csv`);
|
||||
}
|
||||
|
||||
function exportExcel() {
|
||||
const data = formatLeaderboardData();
|
||||
const worksheet = XLSX.utils.json_to_sheet(data);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Scores');
|
||||
XLSX.writeFile(workbook, `${mapData!.taData.gameplayParameters!.beatmap!.name}_qualifier_scores.xlsx`);
|
||||
}
|
||||
|
||||
function exportTXT() {
|
||||
const data = formatLeaderboardData();
|
||||
const text = data.map(x =>
|
||||
Object.entries(x)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n')
|
||||
).join('\n\n');
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
saveAs(blob, `${mapData!.taData.gameplayParameters!.beatmap!.name}_qualifier_scores.txt`);
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
switch(selectedExportType) {
|
||||
case 'json': exportJSON(); break;
|
||||
case 'csv': exportCSV(); break;
|
||||
case 'excel': exportExcel(); break;
|
||||
case 'txt': exportTXT(); break;
|
||||
}
|
||||
showExportDropdown = false;
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
dispatch('close')
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
closePopup();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore missing-declaration -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="popup-overlay" on:click={closePopup} on:keydown={handleKeydown} role="dialog" aria-modal="true">
|
||||
<!-- svelte-ignore missing-declaration -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="popup-content" on:click|stopPropagation>
|
||||
<button class="close-button" on:click={closePopup} aria-label="Close leaderboard">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
<div class="leaderboard-container">
|
||||
<div class="leaderboard-header">
|
||||
<div class="map-info">
|
||||
{#if mapData?.beatsaverData.metadata.songName}
|
||||
<div class="map-image">
|
||||
<img src={mapData.beatsaverData.versions[0].coverURL} alt={mapData?.beatsaverData.metadata.songName} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="map-details">
|
||||
<h2>{mapData?.beatsaverData.metadata.songName}</h2>
|
||||
<p class="sort-type">Sorted by: {QualifierEvent_LeaderboardSort[sortType]}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="export-dropdown">
|
||||
<button
|
||||
class="btn export-btn"
|
||||
class:disabled={scores.length === 0}
|
||||
on:click={() => showExportDropdown = !showExportDropdown}
|
||||
disabled={scores.length === 0}
|
||||
>
|
||||
<span class="material-symbols-outlined">download</span>
|
||||
<span>Export Scores</span>
|
||||
</button>
|
||||
{#if showExportDropdown}
|
||||
<div class="dropdown-content">
|
||||
<select bind:value={selectedExportType} class="export-select">
|
||||
<option value="json">JSON</option>
|
||||
<option value="csv">CSV</option>
|
||||
<option value="excel">Excel</option>
|
||||
<option value="txt">Text</option>
|
||||
</select>
|
||||
<button class="export-action-btn" on:click={handleExport}>
|
||||
Export Scores
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="search-container">
|
||||
<span class="material-icons search-icon">search</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search players..."
|
||||
bind:value={searchQuery}
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="leaderboard-content">
|
||||
<div class="leaderboard-table">
|
||||
<div class="table-header">
|
||||
<div class="rank-col">Rank</div>
|
||||
<div class="player-col">Player</div>
|
||||
<div class="score-col">Score</div>
|
||||
<div class="accuracy-col">Accuracy</div>
|
||||
<div class="combo-col">Cuts & Max Combo</div>
|
||||
<div class="status-col">Full Combo</div>
|
||||
</div>
|
||||
|
||||
{#each paginatedScores as score, index}
|
||||
<div class="table-row" style="--player-color: {score.color}">
|
||||
<div class="rank-col">
|
||||
<div class="rank-number">#{startIndex + index + 1}</div>
|
||||
</div>
|
||||
<div class="player-col">
|
||||
<div class="player-info">
|
||||
<div class="player-name" style="color: {score.color}">{score.username}</div>
|
||||
<div class="player-id">{score.platformId}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-col">
|
||||
<div class="score-value">{formatScore(score.modifiedScore)}</div>
|
||||
<div class="score-max">/ {formatScore(score.maxPossibleScore)}</div>
|
||||
</div>
|
||||
<div class="accuracy-col">
|
||||
<div class="accuracy-value">{formatAccuracy(score.accuracy)}</div>
|
||||
</div>
|
||||
<div class="combo-col">
|
||||
<div class="combo-value">{score.maxCombo}</div>
|
||||
<div class="combo-details">
|
||||
<span class="good-cuts">{score.goodCuts} good</span>
|
||||
{#if score.badCuts > 0}
|
||||
<span class="bad-cuts">{score.badCuts} bad</span>
|
||||
{/if}
|
||||
{#if score.notesMissed > 0}
|
||||
<span class="missed-notes">{score.notesMissed} missed</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-col">
|
||||
{#if score.fullCombo}
|
||||
<div class="status-badge full-combo">
|
||||
<span class="material-icons">star</span>
|
||||
Full Combo
|
||||
</div>
|
||||
{:else}
|
||||
<div class="status-badge incomplete">
|
||||
<span class="material-icons">remove_circle</span>
|
||||
Not FC
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if paginatedScores.length === 0}
|
||||
<div class="no-results">
|
||||
<span class="material-icons">search_off</span>
|
||||
<p>No players found matching your search.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if totalPages > 1}
|
||||
<div class="pagination">
|
||||
<button
|
||||
class="pagination-btn"
|
||||
disabled={currentPage === 1}
|
||||
on:click={() => goToPage(currentPage - 1)}
|
||||
>
|
||||
<span class="material-icons">chevron_left</span>
|
||||
</button>
|
||||
|
||||
{#each getPaginationPages() as page}
|
||||
<button
|
||||
class="pagination-btn {currentPage === page ? 'active' : ''}"
|
||||
on:click={() => goToPage(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
class="pagination-btn"
|
||||
disabled={currentPage === totalPages}
|
||||
on:click={() => goToPage(currentPage + 1)}
|
||||
>
|
||||
<span class="material-icons">chevron_right</span>
|
||||
</button>
|
||||
|
||||
<div class="pagination-info">
|
||||
Page {currentPage} of {totalPages} ({filteredScores.length} players)
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background-color: var(--button-bg);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: var(--button-hover-bg);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
background-color: var(--comfortable-blue);
|
||||
color: white;
|
||||
position: relative;
|
||||
box-shadow: 0 0 10px rgb(71, 73, 202);
|
||||
}
|
||||
|
||||
.export-btn:hover:not(.disabled) {
|
||||
background-color: rgb(71, 73, 202);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.export-btn.disabled {
|
||||
background-color: #4a4a4a;
|
||||
color: #888888;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.export-dropdown {
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 2px solid #4fc3f7;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
z-index: 1001;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 20px rgba(79, 195, 247, 0.3);
|
||||
animation: dropdownSlide 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes dropdownSlide {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: 16px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8px solid transparent;
|
||||
border-right: 8px solid transparent;
|
||||
border-bottom: 8px solid #4fc3f7;
|
||||
}
|
||||
|
||||
.export-select {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.export-select:hover {
|
||||
border-color: #4fc3f7;
|
||||
background-color: rgba(79, 195, 247, 0.05);
|
||||
}
|
||||
|
||||
.export-select:focus {
|
||||
border-color: #4fc3f7;
|
||||
box-shadow: 0 0 0 2px rgba(79, 195, 247, 0.2);
|
||||
background-color: rgba(79, 195, 247, 0.05);
|
||||
}
|
||||
|
||||
.export-action-btn {
|
||||
background-color: var(--pick-green);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.export-action-btn:hover {
|
||||
background-color: var(--pick-green-hover);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.export-action-btn:active {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.popup-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
position: relative;
|
||||
max-width: 95vw;
|
||||
max-height: 95vh;
|
||||
overflow: auto;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1001;
|
||||
transition: background-color 0.2s;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.close-button .material-icons {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.leaderboard-container {
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.leaderboard-header {
|
||||
background: linear-gradient(135deg, var(--accent-color), var(--accent-hover));
|
||||
padding: 2rem;
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.map-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.map-image {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.map-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.map-details h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sort-type {
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 2rem;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
color: var(--text-secondary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 0.75rem 1rem 0.75rem 3rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
width: 300px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.leaderboard-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaderboard-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr 150px 120px 140px 140px;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem 2rem;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr 150px 120px 140px 140px;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
transition: background-color 0.2s;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.rank-number {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.player-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.player-id {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.score-max {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.accuracy-value {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.combo-value {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.combo-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.good-cuts {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.bad-cuts {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.missed-notes {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.full-combo {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.status-badge.incomplete {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.status-badge .material-icons {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
margin-left: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-results .material-icons {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.no-results p {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.leaderboard-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-header,
|
||||
.table-row {
|
||||
grid-template-columns: 60px 1fr 100px 80px;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.combo-col,
|
||||
.status-col {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.map-info {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
margin-left: 0;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -55,6 +55,7 @@ export interface BeatSaverMap {
|
|||
resets: number;
|
||||
};
|
||||
stars: number;
|
||||
maxScore: number;
|
||||
}>;
|
||||
downloadURL: string;
|
||||
previewURL: string;
|
||||
|
|
|
|||
12
src/lib/taDocs/0-TODO
Normal file
12
src/lib/taDocs/0-TODO
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
still left:
|
||||
- the last 3 stateManager docs,
|
||||
- all the base client methods,
|
||||
- all the modals
|
||||
- all the enums
|
||||
- further docs on streamsync
|
||||
- how to correctly load a map
|
||||
- how to handle scores (sec. 6)
|
||||
- whatever else comes to my mind
|
||||
|
||||
Section 6: events you can listen to (realtimescore, matchupdate, etc)
|
||||
Section 7: Best Practices? or something like that, sort of an 'I did this and so should you'
|
||||
29
src/lib/taDocs/1-fam-states-intro.md
Normal file
29
src/lib/taDocs/1-fam-states-intro.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Familiarising yourself with the TA Client
|
||||
It is important that you don't just copy and paste code from here if you want to actually understand the client. The best way to use the client is to test out the features for yourself and play around with what you can do.
|
||||
|
||||
# What does "State-Based" mean?
|
||||
The TournamentAssistant Client being state based means that not every action will result in a database query or ven a request in the first place. A great example of this can be seen on ShyyTAUI when you switch in-between the tabs on the navbar. Once the tournaments are loaded, they are saved in the state of the user. This means that it will take significantly less time to load for clients. If you request the tournaments once, but another user just requested it after a long period of inactivity, the tournaments are loaded in the state and a database query on the backend is not necessary to serve your request, hence the server will respond more promptly.
|
||||
|
||||
This also leads us to event listeners, or just listeners for short. Listeners serve the purpose of making sure that when the state changes on the server, your client also receives the update. A quick example of listeners is:
|
||||
```ts
|
||||
client.stateManager.on('matchCreated', handleMatchCreated);
|
||||
client.stateManager.on('matchDeleted', handleMatchDeleted);
|
||||
client.stateManager.on('matchUpdated', handleMatchUpdated);
|
||||
client.stateManager.on('userConnected', handleUserConnected);
|
||||
client.stateManager.on('userDisconnected', handleUserDisconnected);
|
||||
```
|
||||
<div class="info-box">
|
||||
<span class="material-icons">info</span>
|
||||
<div>
|
||||
<strong>Example:</strong> This is an example from the page of ShyyTAUI where the matches and available players are shown.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
In this case, we use `client.stateManger`, which indicates that we want to listen to the changes in the state. It is often very confusing what the difference between `client.on()` and `client.stateManager.on()` is. The best way to explain it is to say that the `client` is YOU and the `stateManager` is THE SERVER. So when you listen using the `client`(`client.on()`), then you are listening to events you actioned. (`client.on('createdMatch', callback)` for example listens to the confirmation of when you created the match). On the other hand, listening with `client.stateManager.on()` acts as a global state listener(`client.stateManager.on('matchCreated', callback)` listens to when a match is created in general. Such as by another coordinator).
|
||||
|
||||
<div class="warning-box">
|
||||
<span class="material-icons">warning</span>
|
||||
<div>
|
||||
<strong>Note:</strong> This will be explained further in the 'State Manager' section, along with further behaviour descriptors. (most info will even be repeated)
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
# Introduction
|
||||
TournamentAssistant is a piece of woftware which has a backend(TournamentAssistantServer), frontend(TournamentAssistantUI), and in-game plugin(TournamentAssistant). It is important to note before proceeding that TA uses a websocket for connections and DOES NOT have an API. The websocket uses protobuf, so the packets themseves are not readable by humans naturally. That is why the TA client is important. It includes the proto models and deserialises them.
|
||||
## Installing the npm package
|
||||
The client can be installed through the npm repository and has a crucial role in transforming packets and seding them to the websocket.
|
||||
```code
|
||||
npm i moons-ta-client
|
||||
```
|
||||
Once installed we can run some basic scripts in any JS/TS file. I strongly recommend the use of TS, as it is what I will be using in the examples which I provide.
|
||||
|
||||
## Let's run a basic script
|
||||
We will construct a script which will connect to the default TournamentAssistant server and get the information of a tournament.
|
||||
```ts
|
||||
|
|
@ -25,7 +24,12 @@ taClient.disconnect();
|
|||
```
|
||||
You might notice that we have not actually connected to a tournament or fetched any details. This is intentional, as the connectResult already inclides the current state of the given TA server.
|
||||
|
||||
<sub>As of now, it is not possible to host your own TA server. This is not the intention of the new TA, rather it is meant to centralise everything as it is difficult for everyone to swap servers constantly.</sub>
|
||||
<div class="info-box">
|
||||
<span class="material-icons">info</span>
|
||||
<div>
|
||||
<strong>Note:</strong> As of now, it is not possible to host your own TA server. This is not the intention of the new TA, rather it is meant to centralise everything as it is difficult for everyone to swap servers constantly.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
The connectResult has the following schema:
|
||||
```ts
|
||||
13
src/lib/taDocs/1-intro.md
Normal file
13
src/lib/taDocs/1-intro.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Introduction
|
||||
TournamentAssistant is a piece of woftware which has a backend(TournamentAssistantServer), frontend(TournamentAssistantUI), and in-game plugin(TournamentAssistant). It is important to note before proceeding that TA uses a websocket for connections and DOES NOT have a RESTful API. The websocket uses protobuf, so the packets themseves are not readable by humans naturally. That is why the TA client is important. It includes the proto models and deserialises them.
|
||||
|
||||
## Prerequisites
|
||||
You should probably get familiar with:
|
||||
- Serialisation
|
||||
- Typescript
|
||||
- Websocket connections
|
||||
|
||||
Since the TA Client is a websocket connection on the coordinator end, it is important to understand when and how connections can break and how to check for this. Typescript is the preferred language to follow this guide in, so having ablank typescript project or any .ts file that you cna transpile and work with is almost required, although raw javascript also works, but you will have less control.
|
||||
|
||||
## The Guide
|
||||
This guide will show every function within the TournamentAssistant Client and I will also be providing some implementation examples from [ShyyTAUI](/). Keep in mind that you will be seeing code that works in Svelte 4, and I will not always remove irrelevant code, as I want to show direct implementation which includes all of the side-processes being done. I will do my best to keep this documentation as up to date as possible, however TA constantly changes and is still somewhat a work in progress tool. If you beleive something is incorrect in this guide, or would like to make corrections, please contact me on [Discord](https://discord.com/users/469171963236057120).
|
||||
|
|
@ -16,8 +16,8 @@ When you initiate the websocket connection, the initial state of the core server
|
|||
|
||||
You might also notice that the stateManager is the only way to fetch tournament info etc. This is again done to ease development and redice server load.
|
||||
|
||||
<div class="info-box">
|
||||
<span class="material-icons">info</span>
|
||||
<div class="warning-box">
|
||||
<span class="material-icons">question_mark</span>
|
||||
<div>
|
||||
<strong>Will the stateManager be out-of-date?</strong> The stateManager automatically updates its state when a packet is received or sent, so no, you do not have to worry about out-of-date information being stored.
|
||||
</div>
|
||||
38
src/lib/taDocs/2-stateManager-allFunctions.md
Normal file
38
src/lib/taDocs/2-stateManager-allFunctions.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# All of the methods of the StateManager
|
||||
|
||||
Below you can find a table that shows all of the methods of the stateManager.
|
||||
| taClient.stateManager. (method) | Purpose and functionality | Is Async |
|
||||
|:----------------------------------:|:--------------------------|:--------:|
|
||||
|emit()|Emit a custom event. Only use this if you know what you are doing.|True|
|
||||
|getKnownServers()|Get the known servers which have been sent in the connect state. **This is a cached list.**|False|
|
||||
|getMatch()|Get the details of a match.|False|
|
||||
|getMatches()|Get an array of all of the matches in a tournament.|False|
|
||||
|getQualifier()|Get the details of a qualifier event.|False|
|
||||
|getQualifiers()|Get an array of all of the qualifiers in a tournament.|False|
|
||||
|getSelfGuid()|Get the `.guid` property of the currently logged in user or bot token.|False|
|
||||
|getTournament()|Get the information about a tournament.|False|
|
||||
|getTournaments()|Get an array of all of the tournaments of a Core Server|False|
|
||||
|getUser()|Get all of the details of a user in a tournament.|False|
|
||||
|getUsers()|Get an array of all of the users in a tournament.|False|
|
||||
|handlePacket()|Handle the response to a packet sent to you by the TA Core Server. Only use if you know what you are doing.|False|
|
||||
|on()|Standard listener for the taClient. This is how real time score can be subscribed to.|False|
|
||||
|once()|This is essentially the same as `taClient.stateManager.on()` except it unsubscribes after the first event.|False|
|
||||
|removeListener()|Unsibscribe from the events that you have started to listen to.|False|
|
||||
|
||||
## How to listen, what is the general schema?
|
||||
Generally, the format is just:
|
||||
```ts
|
||||
taClient.stateManager.on('eventType', () => {
|
||||
// callback function
|
||||
})
|
||||
```
|
||||
or
|
||||
```ts
|
||||
async function handleEvent(params) {
|
||||
// code here
|
||||
}
|
||||
|
||||
taClient.stateManager.on('eventType', handleEvent);
|
||||
```
|
||||
|
||||
All of the event types and how to handle them are within this State Manager section.
|
||||
15
src/lib/taDocs/2-stateManager-emit.md
Normal file
15
src/lib/taDocs/2-stateManager-emit.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# The stateManager.emit() method
|
||||
|
||||
<div class="info-box">
|
||||
<span class="material-icons">info</span>
|
||||
<div>
|
||||
<strong>Async:</strong> This is a method you would want to use await with
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## General usage:
|
||||
```ts
|
||||
await taClient.stateManager.emit('eventType', params);
|
||||
```
|
||||
|
||||
I will not proide further documentation for this method, as it should NOT be used. Every way you could use it exists with another method.
|
||||
31
src/lib/taDocs/2-stateManager-getKnownServers.md
Normal file
31
src/lib/taDocs/2-stateManager-getKnownServers.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# The stateManager.getKnownServers() method
|
||||
This documents the `stateManager.getKnownServers() method.`
|
||||
|
||||
## General usage:
|
||||
```ts
|
||||
const servers: CoreServer[] = taClient.stateManager.getKnownServers();
|
||||
```
|
||||
|
||||
This method will return an array of the known `CoreServer`s. A `CoreServer` can be described by the following TypeScript Interface:
|
||||
```ts
|
||||
interface CoreServer {
|
||||
/**
|
||||
* @generated from protobuf field: string name = 1;
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @generated from protobuf field: string address = 2;
|
||||
*/
|
||||
address: string;
|
||||
/**
|
||||
* @generated from protobuf field: int32 port = 3;
|
||||
*/
|
||||
port: number;
|
||||
/**
|
||||
* @generated from protobuf field: int32 websocket_port = 4;
|
||||
*/
|
||||
websocketPort: number;
|
||||
}
|
||||
```
|
||||
|
||||
This means that a `CoreServer` has a `name`(such as 'Default Server' in the case of the default server), an `address`('server.tournamentassistant.net' in the case of the default server), a `port` which the players connect to(8675 by default), and a `websocketPort` to which coordinators, overlays and the client connects to(by default 8676)
|
||||
84
src/lib/taDocs/2-stateManager-getMatch.md
Normal file
84
src/lib/taDocs/2-stateManager-getMatch.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# The stateManager.getMatch() method
|
||||
This documents the `stateManager.getMatch()` method.
|
||||
|
||||
This method has input parameters. The function could easily be defined as:
|
||||
```ts
|
||||
function getMatch(tournamentGuid: string, matchGuid: string): Match | undefined {
|
||||
// backend logic
|
||||
}
|
||||
```
|
||||
## General usage:
|
||||
```ts
|
||||
const matchResponse: Match | undefined = taClient.stateManager.getMatch(tournamentGuid, matchGuid);
|
||||
```
|
||||
|
||||
This method will return either the `Match` object or undefined. A `Match` can be described by the following TypeScript Interface:
|
||||
```ts
|
||||
interface Match {
|
||||
/**
|
||||
* @generated from protobuf field: string guid = 1;
|
||||
*/
|
||||
guid: string;
|
||||
/**
|
||||
* @generated from protobuf field: repeated string associated_users = 2;
|
||||
*/
|
||||
associatedUsers: string[];
|
||||
/**
|
||||
* @generated from protobuf field: string leader = 3;
|
||||
*/
|
||||
leader: string;
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.Map selected_map = 4;
|
||||
*/
|
||||
selectedMap?: Map;
|
||||
}
|
||||
```
|
||||
|
||||
For the definition of a Map please look at the [Modals -> Map page](/documentation#Modals/4-Map)
|
||||
|
||||
A direct implementation:
|
||||
```ts
|
||||
try {
|
||||
// Check if the client is connected, connect to the server if not
|
||||
if(!client.isConnected) {
|
||||
// Connec to the server
|
||||
const connectResult = await client.connect($TAServerUrl, $TAServerPort);
|
||||
// Check if the connection is successful
|
||||
if (connectResult.details.oneofKind === "connect" && connectResult.type === Response_ResponseType.Fail) {
|
||||
throw new Error(connectResult.details.connect.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Get tournament and match data
|
||||
tournament = client.stateManager.getTournament(tournamentGuid);
|
||||
|
||||
// This is the implementation, we just set the match object to the one we neem
|
||||
match = client.stateManager.getMatch(tournamentGuid, matchGuid);
|
||||
|
||||
// Check if the logged in coordinator or user is added to the match properly
|
||||
if(!match?.associatedUsers.includes(client.stateManager.getSelfGuid())) {
|
||||
// If not, add the user to the match
|
||||
await addSelfToMatch();
|
||||
}
|
||||
|
||||
// Check to see if we are actually viewing a valid match
|
||||
if (!match) {
|
||||
throw new Error('Match not found');
|
||||
}
|
||||
|
||||
// This function just does some magic with the beatsaver data of the currently selected map
|
||||
await refreshMatchDetails(match);
|
||||
} catch (err) {
|
||||
console.error('Error fetching match data:', err);
|
||||
error = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
} finally {
|
||||
// Stop loading the page and allow everything to show data without errors
|
||||
isLoading = false;
|
||||
}
|
||||
```
|
||||
<div class="info-box">
|
||||
<span class="material-icons">info</span>
|
||||
<div>
|
||||
<strong>Example:</strong> This is an example from the page of ShyyTAUI within the match coorination page.
|
||||
</div>
|
||||
</div>
|
||||
37
src/lib/taDocs/2-stateManager-getMatches.md
Normal file
37
src/lib/taDocs/2-stateManager-getMatches.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# The stateManager.getMatches() method
|
||||
This documents the `stateManager.getMatches()` method.
|
||||
|
||||
This method has input parameters. The function could easily be defined as:
|
||||
```ts
|
||||
function getMatches(tournamentGuid: string,): Match[] | undefined {
|
||||
// backend logic
|
||||
}
|
||||
```
|
||||
## General usage:
|
||||
```ts
|
||||
const matchesResponse: Match[] | undefined = taClient.stateManager.getMatches(tournamentGuid);
|
||||
```
|
||||
|
||||
This method will return either an array of the `Match` object or undefined. A `Match` can be described by the following TypeScript Interface:
|
||||
```ts
|
||||
interface Match {
|
||||
/**
|
||||
* @generated from protobuf field: string guid = 1;
|
||||
*/
|
||||
guid: string;
|
||||
/**
|
||||
* @generated from protobuf field: repeated string associated_users = 2;
|
||||
*/
|
||||
associatedUsers: string[];
|
||||
/**
|
||||
* @generated from protobuf field: string leader = 3;
|
||||
*/
|
||||
leader: string;
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.Map selected_map = 4;
|
||||
*/
|
||||
selectedMap?: Map;
|
||||
}
|
||||
```
|
||||
|
||||
For the definition of a Map please look at the [Modals -> Map page](/documentation#Modals/4-Map)
|
||||
65
src/lib/taDocs/2-stateManager-getQualifier.md
Normal file
65
src/lib/taDocs/2-stateManager-getQualifier.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# The stateManager.getQualifier() method
|
||||
This documents the `stateManager.getQualifier()` method.
|
||||
|
||||
This method has input parameters. The function could easily be defined as:
|
||||
```ts
|
||||
function getQualifier(tournamentGuid: string, qualifierGuid: string): QualifierEvent | undefined {
|
||||
// backend logic
|
||||
}
|
||||
```
|
||||
## General usage:
|
||||
```ts
|
||||
const qualifier: QualifierEvent | undefined = taClient.stateManager.getQualifier(tournamentGuid, qualifierGuid);
|
||||
```
|
||||
|
||||
This method will return either the `QualifierEvent` object or undefined. A `QualifierEvent` can be described by the following TypeScript Interface:
|
||||
```ts
|
||||
interface QualifierEvent {
|
||||
/**
|
||||
* @generated from protobuf field: string guid = 1;
|
||||
*/
|
||||
guid: string;
|
||||
/**
|
||||
* @generated from protobuf field: string name = 2;
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @generated from protobuf field: bytes image = 3;
|
||||
*/
|
||||
image: Uint8Array;
|
||||
/**
|
||||
* @generated from protobuf field: proto.discord.Channel info_channel = 4;
|
||||
*/
|
||||
infoChannel?: {
|
||||
/**
|
||||
* @generated from protobuf field: string id = 1;
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @generated from protobuf field: string name = 2;
|
||||
*/
|
||||
name: string;
|
||||
};
|
||||
/**
|
||||
* @generated from protobuf field: repeated proto.models.Map qualifier_maps = 5;
|
||||
*/
|
||||
qualifierMaps: Map[];
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.QualifierEvent.EventSettings flags = 6;
|
||||
*/
|
||||
flags: QualifierEvent_EventSettings;
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.QualifierEvent.LeaderboardSort sort = 7;
|
||||
*/
|
||||
sort: QualifierEvent_LeaderboardSort;
|
||||
}
|
||||
```
|
||||
|
||||
For the definition of the `QualifierEvent_EventSettings` look at the [Enums -> QualifierEvent_EventSettings](/documentation#Enums/5-QualifierEvent_EventSettings) and for the `QualifierEvent_LeaderboardSort` look at the [Enums -> QualifierEvent_EventSettings](/documentation#Enums/5-QualifierEvent_LeaderboardSort) page.
|
||||
|
||||
<div class="info-box">
|
||||
<span class="material-icons">info</span>
|
||||
<div>
|
||||
<strong>Example:</strong> A direct implementation example is not present, since this method is not used in ShyyTAUI.
|
||||
</div>
|
||||
</div>
|
||||
71
src/lib/taDocs/2-stateManager-getQualifiers.md
Normal file
71
src/lib/taDocs/2-stateManager-getQualifiers.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# The stateManager.getQualifiers() method
|
||||
This documents the `stateManager.getQualifiers()` method.
|
||||
|
||||
This method has input parameters. The function could easily be defined as:
|
||||
```ts
|
||||
function getQualifiers(tournamentGuid: string): QualifierEvent[] | undefined {
|
||||
// backend logic
|
||||
}
|
||||
```
|
||||
## General usage:
|
||||
```ts
|
||||
const qualifiers: QualifierEvent[] | undefined = taClient.stateManager.getQualifiers(tournamentGuid);
|
||||
```
|
||||
|
||||
This method will return either an array of the `QualifierEvent` object or undefined. A `QualifierEvent` can be described by the following TypeScript Interface:
|
||||
```ts
|
||||
interface QualifierEvent {
|
||||
/**
|
||||
* @generated from protobuf field: string guid = 1;
|
||||
*/
|
||||
guid: string;
|
||||
/**
|
||||
* @generated from protobuf field: string name = 2;
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @generated from protobuf field: bytes image = 3;
|
||||
*/
|
||||
image: Uint8Array;
|
||||
/**
|
||||
* @generated from protobuf field: proto.discord.Channel info_channel = 4;
|
||||
*/
|
||||
infoChannel?: {
|
||||
/**
|
||||
* @generated from protobuf field: string id = 1;
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @generated from protobuf field: string name = 2;
|
||||
*/
|
||||
name: string;
|
||||
};
|
||||
/**
|
||||
* @generated from protobuf field: repeated proto.models.Map qualifier_maps = 5;
|
||||
*/
|
||||
qualifierMaps: Map[];
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.QualifierEvent.EventSettings flags = 6;
|
||||
*/
|
||||
flags: QualifierEvent_EventSettings;
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.QualifierEvent.LeaderboardSort sort = 7;
|
||||
*/
|
||||
sort: QualifierEvent_LeaderboardSort;
|
||||
}
|
||||
```
|
||||
<div class="info-box">
|
||||
<span class="material-icons">info</span>
|
||||
<div>
|
||||
<strong>Good to know:</strong> For the sake of simplification, the type of <code>infoChannel</code> has been substituted. Find the documentation on the original type <a href="/documentation#Modals/4-modals-Channel">here: Modals -> Channel</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
For the definition of the `QualifierEvent_EventSettings` look at the [Enums -> QualifierEvent_EventSettings](/documentation#Enums/5-QualifierEvent_EventSettings) and for the `QualifierEvent_LeaderboardSort` look at the [Enums -> QualifierEvent_EventSettings](/documentation#Enums/5-QualifierEvent_LeaderboardSort) page.
|
||||
|
||||
<div class="info-box">
|
||||
<span class="material-icons">info</span>
|
||||
<div>
|
||||
<strong>Example:</strong> A direct implementation example is not present, since this method is not used in ShyyTAUI.
|
||||
</div>
|
||||
</div>
|
||||
28
src/lib/taDocs/2-stateManager-getSelfGuid.md
Normal file
28
src/lib/taDocs/2-stateManager-getSelfGuid.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# The stateManager.getSelfGuid() method
|
||||
This documents the `stateManager.getSelfGuid()` method.
|
||||
|
||||
This is perhaps the simplest method of the `stateManager`, as its response is a simple string which is the guid of the currently connected client.
|
||||
## General usage:
|
||||
```ts
|
||||
const myGuid: string = taClient.stateManager.getSelfGuid();
|
||||
```
|
||||
|
||||
There is not much more to explain here. This method will simply return the currently connected client's GUID.
|
||||
|
||||
It can be useful in scenarios such as:
|
||||
```ts
|
||||
async function addSelfToMatch() {
|
||||
if(!match?.associatedUsers.includes(client.stateManager.getSelfGuid())) {
|
||||
const response = await client.addUserToMatch(tournamentGuid, matchGuid, client.stateManager.getSelfGuid());
|
||||
console.log("Added Self to match!", response);
|
||||
}
|
||||
}
|
||||
```
|
||||
<div class="info-box">
|
||||
<span class="material-icons">info</span>
|
||||
<div>
|
||||
<strong>Example:</strong> This is an example from the page of ShyyTAUI within the match coorination page.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
The example above demonstrates the `addSelfToMatch` method used in ShyyTAUI in order to ensure that the coordinator is correctly connected and associated to the match.
|
||||
69
src/lib/taDocs/2-stateManager-getTournament.md
Normal file
69
src/lib/taDocs/2-stateManager-getTournament.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# The stateManager.getTournament() method
|
||||
This documents the `stateManager.getTournament()` method.
|
||||
|
||||
This method has input parameters. The function could easily be defined as:
|
||||
```ts
|
||||
function getTournament(tournamentGuid: string): Tournament | undefined {
|
||||
// backend logic
|
||||
}
|
||||
```
|
||||
## General usage:
|
||||
```ts
|
||||
const tournament: Tournament | undefined = taClient.stateManager.getTournament(tournamentGuid);
|
||||
```
|
||||
|
||||
This method will return either the `Tournament` object or undefined. A `Tournament` can be described by the following TypeScript Interface:
|
||||
```ts
|
||||
interface Tournament {
|
||||
/**
|
||||
* @generated from protobuf field: string guid = 1;
|
||||
*/
|
||||
guid: string;
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.Tournament.TournamentSettings settings = 2;
|
||||
*/
|
||||
settings?: Tournament_TournamentSettings;
|
||||
/**
|
||||
* @generated from protobuf field: repeated proto.models.User users = 3;
|
||||
*/
|
||||
users: User[];
|
||||
/**
|
||||
* @generated from protobuf field: repeated proto.models.Match matches = 4;
|
||||
*/
|
||||
matches: Match[];
|
||||
/**
|
||||
* @generated from protobuf field: repeated proto.models.QualifierEvent qualifiers = 5;
|
||||
*/
|
||||
qualifiers: QualifierEvent[];
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.CoreServer server = 6;
|
||||
*/
|
||||
server?: CoreServer;
|
||||
}
|
||||
```
|
||||
|
||||
- For the definition of a Tournament_TournamentSettings please look at the [Modals -> Tournament_TournamentSettings page](/documentation#Modals/4-Tournament_TournamentSettings)
|
||||
- For the definition of a User please look at the [Modals -> User page](/documentation#Modals/4-User)
|
||||
- For the definition of a Match please look at the [Modals -> Match page](/documentation#Modals/4-Match)
|
||||
- For the definition of a QualifierEvent please look at the [Modals -> QualifierEvent page](/documentation#Modals/4-QualifierEvent)
|
||||
- For the definition of a CoreServer please look at the [Modals -> CoreServer page](/documentation#Modals/4-CoreServer)
|
||||
|
||||
A direct implementation:
|
||||
```ts
|
||||
// Get the tournament data, and check if the current user is within the users that are currently in the touranament
|
||||
if(!client.stateManager.getTournament(tournamentGuid)!.users.some(user => user.guid == client.stateManager.getSelfGuid())) {
|
||||
// If the user is not in the tournament, join it
|
||||
const joinResult = await client.joinTournament(tournamentGuid);
|
||||
// Check if the join was successful
|
||||
if (joinResult.details.oneofKind !== 'join' || joinResult.type === Response_ResponseType.Fail) {
|
||||
// Throw an error if not
|
||||
throw new Error('Could not join tournament');
|
||||
}
|
||||
}
|
||||
```
|
||||
<div class="info-box">
|
||||
<span class="material-icons">info</span>
|
||||
<div>
|
||||
<strong>Example:</strong> This is an example from the page of ShyyTAUI within the qualifiers page.
|
||||
</div>
|
||||
</div>
|
||||
60
src/lib/taDocs/2-stateManager-getTournaments.md
Normal file
60
src/lib/taDocs/2-stateManager-getTournaments.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# The stateManager.getTournamenst() method
|
||||
This documents the `stateManager.getTournaments()` method.
|
||||
|
||||
```ts
|
||||
function getTournaments(): Tournament[] | undefined {
|
||||
// backend logic
|
||||
}
|
||||
```
|
||||
## General usage:
|
||||
```ts
|
||||
const tournaments: Tournament[] | undefined = taClient.stateManager.getTournaments();
|
||||
```
|
||||
|
||||
This method will return either an array of the `Tournament` object or undefined. A `Tournament` can be described by the following TypeScript Interface:
|
||||
```ts
|
||||
interface Tournament {
|
||||
/**
|
||||
* @generated from protobuf field: string guid = 1;
|
||||
*/
|
||||
guid: string;
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.Tournament.TournamentSettings settings = 2;
|
||||
*/
|
||||
settings?: Tournament_TournamentSettings;
|
||||
/**
|
||||
* @generated from protobuf field: repeated proto.models.User users = 3;
|
||||
*/
|
||||
users: User[];
|
||||
/**
|
||||
* @generated from protobuf field: repeated proto.models.Match matches = 4;
|
||||
*/
|
||||
matches: Match[];
|
||||
/**
|
||||
* @generated from protobuf field: repeated proto.models.QualifierEvent qualifiers = 5;
|
||||
*/
|
||||
qualifiers: QualifierEvent[];
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.CoreServer server = 6;
|
||||
*/
|
||||
server?: CoreServer;
|
||||
}
|
||||
```
|
||||
|
||||
- For the definition of a Tournament_TournamentSettings please look at the [Modals -> Tournament_TournamentSettings page](/documentation#Modals/4-Tournament_TournamentSettings)
|
||||
- For the definition of a User please look at the [Modals -> User page](/documentation#Modals/4-User)
|
||||
- For the definition of a Match please look at the [Modals -> Match page](/documentation#Modals/4-Match)
|
||||
- For the definition of a QualifierEvent please look at the [Modals -> QualifierEvent page](/documentation#Modals/4-QualifierEvent)
|
||||
- For the definition of a CoreServer please look at the [Modals -> CoreServer page](/documentation#Modals/4-CoreServer)
|
||||
|
||||
A direct implementation:
|
||||
```ts
|
||||
// Get tournaments from the client
|
||||
const tournamentsList = client.stateManager.getTournaments();
|
||||
```
|
||||
<div class="info-box">
|
||||
<span class="material-icons">info</span>
|
||||
<div>
|
||||
<strong>Example:</strong> This is an example from the page of ShyyTAUI within the tournament list / select page.
|
||||
</div>
|
||||
</div>
|
||||
116
src/lib/taDocs/2-stateManager-getUser.md
Normal file
116
src/lib/taDocs/2-stateManager-getUser.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# The stateManager.getUser() method
|
||||
This documents the `stateManager.getUser()` method.
|
||||
|
||||
This method has input parameters. The function could easily be defined as:
|
||||
```ts
|
||||
function getUser(tournamentGuid: string, userGuid: string): User | undefined {
|
||||
// backend logic
|
||||
}
|
||||
```
|
||||
## General usage:
|
||||
```ts
|
||||
const user: User | undefined = taClient.stateManager.getUser(tournamentGuid, userGuid);
|
||||
```
|
||||
|
||||
This method will return either the `User` object or undefined. A `User` can be described by the following TypeScript Interface:
|
||||
```ts
|
||||
interface User {
|
||||
/**
|
||||
* @generated from protobuf field: string guid = 1;
|
||||
*/
|
||||
guid: string;
|
||||
/**
|
||||
* @generated from protobuf field: string name = 2;
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @generated from protobuf field: string platform_id = 3;
|
||||
*/
|
||||
platformId: string;
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.User.ClientTypes client_type = 4;
|
||||
*/
|
||||
clientType: User_ClientTypes;
|
||||
/**
|
||||
* @generated from protobuf field: string team_id = 5;
|
||||
*/
|
||||
teamId: string;
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.User.PlayStates play_state = 6;
|
||||
*/
|
||||
playState: User_PlayStates;
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.User.DownloadStates download_state = 7;
|
||||
*/
|
||||
downloadState: User_DownloadStates;
|
||||
/**
|
||||
* @generated from protobuf field: repeated string mod_list = 8;
|
||||
*/
|
||||
modList: string[];
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.User.Point stream_screen_coordinates = 9;
|
||||
*/
|
||||
streamScreenCoordinates?: User_Point;
|
||||
/**
|
||||
* @generated from protobuf field: int64 stream_delay_ms = 10;
|
||||
*/
|
||||
streamDelayMs: bigint;
|
||||
/**
|
||||
* @generated from protobuf field: int64 stream_sync_start_ms = 11;
|
||||
*/
|
||||
streamSyncStartMs: bigint;
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.User.DiscordInfo discord_info = 12;
|
||||
*/
|
||||
discordInfo?: User_DiscordInfo;
|
||||
/**
|
||||
* @generated from protobuf field: bytes user_image = 13;
|
||||
*/
|
||||
userImage: Uint8Array;
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.Permissions permissions = 14;
|
||||
*/
|
||||
permissions: Permissions;
|
||||
}
|
||||
```
|
||||
|
||||
- For the definition of a User_ClientTypes please look at the [Enums -> User_ClientTypes page](/documentation#Enums/5-User_ClientTypes)
|
||||
- For the definition of a User_PlayStates please look at the [Enums -> User_PlayStates page](/documentation#Enums/5-User_PlayStates)
|
||||
- For the definition of a User_DownloadStates please look at the [Enums -> User_DownloadStates page](/documentation#Enums/5-User_DownloadStates)
|
||||
- For the definition of a User_Point please look at the [Modals -> User_Point page](/documentation#Modals/4-User_Point)
|
||||
- For the definition of a User_DiscordInfo please look at the [Modals -> User_DiscordInfo page](/documentation#Modals/4-User_DiscordInfo)
|
||||
- For the definition of a Permissions please look at the [Enums -> Permissions page](/documentation#Enums/5-Permissions)
|
||||
|
||||
A direct implementation:
|
||||
```ts
|
||||
async function refreshMatchDetails(match: Match) {
|
||||
// Get match players
|
||||
// Fetch each of the players in the match my using map on the associated users array which contains the connected user's guid
|
||||
matchPlayers = match.associatedUsers
|
||||
.map(userGuid => client.stateManager.getUser(tournamentGuid, userGuid))
|
||||
// Make sure that the client is a player(0) and not a websocket or REST connection (1 or 2)
|
||||
.filter(user => user && user.clientType === 0) as User[];
|
||||
|
||||
// Remaining logic in the function
|
||||
// Get current song if available
|
||||
currentSong = match.selectedMap?.gameplayParameters || null;
|
||||
if(currentSong) {
|
||||
try {
|
||||
// Fetch the map beatsaver data
|
||||
const tempMapData = await fetchMapByLevelId(currentSong.beatmap.levelId);
|
||||
|
||||
if(tempMapData) {
|
||||
currentSongData = tempMapData;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Current song beatsaver data fetch failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
<div class="info-box">
|
||||
<span class="material-icons">info</span>
|
||||
<div>
|
||||
<strong>Example:</strong> This is an example from the page of ShyyTAUI within the match cordination page.
|
||||
</div>
|
||||
</div>
|
||||
89
src/lib/taDocs/2-stateManager-getUsers.md
Normal file
89
src/lib/taDocs/2-stateManager-getUsers.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# The stateManager.getUsers() method
|
||||
This documents the `stateManager.getUsers()` method.
|
||||
|
||||
This method has input parameters. The function could easily be defined as:
|
||||
```ts
|
||||
function getUsers(tournamentGuid: string): User[] | undefined {
|
||||
// backend logic
|
||||
}
|
||||
```
|
||||
## General usage:
|
||||
```ts
|
||||
const users: User[] | undefined = taClient.stateManager.getUsers(tournamentGuid);
|
||||
```
|
||||
|
||||
This method will return either an array of the `User` object or undefined. A `User` can be described by the following TypeScript Interface:
|
||||
```ts
|
||||
interface User {
|
||||
/**
|
||||
* @generated from protobuf field: string guid = 1;
|
||||
*/
|
||||
guid: string;
|
||||
/**
|
||||
* @generated from protobuf field: string name = 2;
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @generated from protobuf field: string platform_id = 3;
|
||||
*/
|
||||
platformId: string;
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.User.ClientTypes client_type = 4;
|
||||
*/
|
||||
clientType: User_ClientTypes;
|
||||
/**
|
||||
* @generated from protobuf field: string team_id = 5;
|
||||
*/
|
||||
teamId: string;
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.User.PlayStates play_state = 6;
|
||||
*/
|
||||
playState: User_PlayStates;
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.User.DownloadStates download_state = 7;
|
||||
*/
|
||||
downloadState: User_DownloadStates;
|
||||
/**
|
||||
* @generated from protobuf field: repeated string mod_list = 8;
|
||||
*/
|
||||
modList: string[];
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.User.Point stream_screen_coordinates = 9;
|
||||
*/
|
||||
streamScreenCoordinates?: User_Point;
|
||||
/**
|
||||
* @generated from protobuf field: int64 stream_delay_ms = 10;
|
||||
*/
|
||||
streamDelayMs: bigint;
|
||||
/**
|
||||
* @generated from protobuf field: int64 stream_sync_start_ms = 11;
|
||||
*/
|
||||
streamSyncStartMs: bigint;
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.User.DiscordInfo discord_info = 12;
|
||||
*/
|
||||
discordInfo?: User_DiscordInfo;
|
||||
/**
|
||||
* @generated from protobuf field: bytes user_image = 13;
|
||||
*/
|
||||
userImage: Uint8Array;
|
||||
/**
|
||||
* @generated from protobuf field: proto.models.Permissions permissions = 14;
|
||||
*/
|
||||
permissions: Permissions;
|
||||
}
|
||||
```
|
||||
|
||||
- For the definition of a User_ClientTypes please look at the [Enums -> User_ClientTypes page](/documentation#Enums/5-User_ClientTypes)
|
||||
- For the definition of a User_PlayStates please look at the [Enums -> User_PlayStates page](/documentation#Enums/5-User_PlayStates)
|
||||
- For the definition of a User_DownloadStates please look at the [Enums -> User_DownloadStates page](/documentation#Enums/5-User_DownloadStates)
|
||||
- For the definition of a User_Point please look at the [Modals -> User_Point page](/documentation#Modals/4-User_Point)
|
||||
- For the definition of a User_DiscordInfo please look at the [Modals -> User_DiscordInfo page](/documentation#Modals/4-User_DiscordInfo)
|
||||
- For the definition of a Permissions please look at the [Enums -> Permissions page](/documentation#Enums/5-Permissions)
|
||||
|
||||
<div class="info-box">
|
||||
<span class="material-icons">info</span>
|
||||
<div>
|
||||
<strong>Example:</strong> A direct example is not available, as this function is not used in ShyyTAUI.
|
||||
</div>
|
||||
</div>
|
||||
11
src/lib/taDocs/2-stateManager-handlePacket.md
Normal file
11
src/lib/taDocs/2-stateManager-handlePacket.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# The stateManager.handlePacket() method
|
||||
This method should NOT be used, as it is already handled, just exists.
|
||||
|
||||
## General usage:
|
||||
```ts
|
||||
await taClient.stateManager.handlePacket(packet: Packet);
|
||||
```
|
||||
|
||||
For the definition of a `Packet` please refer to [Modals -> Packet](/documentation#Modals/Packet)
|
||||
|
||||
I will not proide further documentation for this method, as it should NOT be used. Every way you could use it exists with another method.
|
||||
48
src/lib/taDocs/2-stateManager-intro.md
Normal file
48
src/lib/taDocs/2-stateManager-intro.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# An Introduction to the StateManager
|
||||
The `client.stateManager`(`stateManager` for short), is the simple way of communicating and syncing with the state on the `CoreServer`. If you alrady understand what 'state-based' means, then you may skip this page.
|
||||
|
||||
The `stateManager` is the way you would fetch tournaments, get matches, or do anything related to getting specific data that does not require a database query. It is best if you are familiar with what methods it has and how you can use them.
|
||||
|
||||
For the ease of this explanation let me provide an example:
|
||||
```ts
|
||||
onMount(async() => {
|
||||
// Check if the user is logged in
|
||||
if ($authTokenStore) {
|
||||
// Fetch the match data that we need to display (current map etc. This function will appear later in docs too)
|
||||
await fetchMatchData();
|
||||
// Using stateManger listen to global changes to our match
|
||||
client.stateManager.on('matchDeleted', handleMatchDeleted);
|
||||
client.stateManager.on('matchUpdated', handleMatchUpdated);
|
||||
client.stateManager.on('userDisconnected', handleUserDisconnected);
|
||||
client.stateManager.on('userUpdated', handleUserUpdated);
|
||||
// Using the client listen to events that will not be stored in the state, such as scores and song finish events
|
||||
client.on('realtimeScore', handleRealtimeScoreUpdate);
|
||||
client.on('songFinished', handleSongFinished);
|
||||
|
||||
// This is a helper function you can ignore, it just prevents the user from reloading the page while in the match
|
||||
window.addEventListener('beforeunload', beforeUnloadHandler);
|
||||
} else {
|
||||
// If the user is not authenticated, they will be thrown to the discord authentication page
|
||||
window.location.href = "/discordAuth"
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Remove the listeners that were added on mount
|
||||
client.stateManager.removeListener('matchUpdated', handleMatchUpdated);
|
||||
client.stateManager.removeListener('matchDeleted', handleMatchDeleted);
|
||||
client.stateManager.removeListener('userDisconnected', handleUserDisconnected);
|
||||
client.stateManager.removeListener('userUpdated', handleUserUpdated);
|
||||
client.removeListener('realtimeScore', handleRealtimeScoreUpdate);
|
||||
client.removeListener('songFinished', handleSongFinished);
|
||||
|
||||
// Remove the no-refresh behaviour
|
||||
window.removeEventListener('beforeunload', beforeUnloadHandler);
|
||||
});
|
||||
```
|
||||
<div class="info-box">
|
||||
<span class="material-icons">info</span>
|
||||
<div>
|
||||
<strong>Example:</strong> This is an example from the page of ShyyTAUI within the match coorination page.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -76,20 +76,3 @@ The table below shows all of the functions of the taClient. From this point onwa
|
|||
|
||||
These are the main functions of the taClient. It is important to note that these are mostly asyncronous and must be awaited for if you wish to use the response they provide. There is however another important part of the taClient, which is the `taClient.stateManger`. This `stateManger` also has more functions which I have also provided in a table below. The `stateManger` loads its data once it connects to the TA CoreServer.
|
||||
|
||||
| taClient.stateManager. (function) | Purpose and functionality | Is Async |
|
||||
|:----------------------------------:|:--------------------------|:--------:|
|
||||
|emit()|Emit a custom event. Only use this if you know what you are doing.|True|
|
||||
|getKnownServers()|Get the known servers which have been sent in the connect state. **This is a cached list.**|False|
|
||||
|getMatch()|Get the details of a match.|False|
|
||||
|getMatches()|Get an array of all of the matches in a tournament.|False|
|
||||
|getQualifier()|Get the details of a qualifier event.|False|
|
||||
|getQualifiers()|Get an array of all of the qualifiers in a tournament.|False|
|
||||
|getSelfGuid()|Get the `.guid` property of the currently logged in user or bot token.|False|
|
||||
|getTournament()|Get the information about a tournament.|False|
|
||||
|getTournaments()|Get an array of all of the tournaments of a Core Server|False|
|
||||
|getUser()|Get all of the details of a user in a tournament.|False|
|
||||
|getUsers()|Get an array of all of the users in a tournament.|False|
|
||||
|handlePacket()|Handle the response to a packet sent to you by the TA Core Server. Only use if you know what you are doing.|False|
|
||||
|**on()**|Standard listener for the taClient. This is how real time score can be subscribed to.|False|
|
||||
|once()|**SAME AS ON? WHAT IS THE DIFFERENCE BETWEEN TACLIENT.NO AND STATEMANAGER.ON????**|False|
|
||||
|removeListener()|**why can I subscribe to events in the statemanager? I am quite unclear about this hehe**|False|
|
||||
0
src/lib/taDocs/4-models-Map.md
Normal file
0
src/lib/taDocs/4-models-Map.md
Normal file
267
src/lib/taDocs/qualifierLeaderboardSort.md
Normal file
267
src/lib/taDocs/qualifierLeaderboardSort.md
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
# QualifierEvent_LeaderboardSort Enum
|
||||
|
||||
This enum defines all supported leaderboard sorting strategies for qualifier events in the TournamentAssistant Client.
|
||||
|
||||
Each sort type allows leaderboard data to be ranked according to specific gameplay metrics, with different sorting modes available.
|
||||
|
||||
## Sorting Modes
|
||||
|
||||
- **Default (Descending)**
|
||||
Sorts scores from highest to lowest. This is the default for most metrics where higher numbers are better (e.g., ModifiedScore, MaxCombo).
|
||||
|
||||
- **Ascending**
|
||||
Sorts scores from lowest to highest. Ideal for metrics where fewer is better (e.g., NotesMissed, BadCuts).
|
||||
|
||||
- **Target**
|
||||
Sorts based on how close a score is to a predefined target value. The absolute difference is used for ranking (i.e., `|target - playerValue|`), with the smallest difference ranked highest.
|
||||
|
||||
---
|
||||
|
||||
## Enum Values
|
||||
|
||||
### ModifiedScore
|
||||
```protobuf
|
||||
ModifiedScore = 0;
|
||||
````
|
||||
|
||||
Sorts by Modified Score, descending (highest first).
|
||||
|
||||
### ModifiedScoreAscending
|
||||
|
||||
```protobuf
|
||||
ModifiedScoreAscending = 1;
|
||||
```
|
||||
|
||||
Sorts by Modified Score, ascending (lowest first).
|
||||
|
||||
### ModifiedScoreTarget
|
||||
|
||||
```protobuf
|
||||
ModifiedScoreTarget = 2;
|
||||
```
|
||||
|
||||
Sorts by proximity to a target Modified Score.
|
||||
Example with target = 107000:
|
||||
|
||||
```
|
||||
Player A: 107114 → 114
|
||||
Player B: 107310 → 310
|
||||
Player C: 103851 → 3149
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### NotesMissed
|
||||
|
||||
```protobuf
|
||||
NotesMissed = 3;
|
||||
```
|
||||
|
||||
Sorts by number of missed notes, descending.
|
||||
|
||||
### NotesMissedAscending
|
||||
|
||||
```protobuf
|
||||
NotesMissedAscending = 4;
|
||||
```
|
||||
|
||||
Sorts by number of missed notes, ascending.
|
||||
|
||||
### NotesMissedTarget
|
||||
|
||||
```protobuf
|
||||
NotesMissedTarget = 5;
|
||||
```
|
||||
|
||||
Sorts by proximity to a target missed notes value.
|
||||
|
||||
---
|
||||
|
||||
### BadCuts
|
||||
|
||||
```protobuf
|
||||
BadCuts = 6;
|
||||
```
|
||||
|
||||
Sorts by number of bad cuts, descending.
|
||||
|
||||
### BadCutsAscending
|
||||
|
||||
```protobuf
|
||||
BadCutsAscending = 7;
|
||||
```
|
||||
|
||||
Sorts by number of bad cuts, ascending.
|
||||
Example:
|
||||
|
||||
```
|
||||
28
|
||||
39
|
||||
43
|
||||
```
|
||||
|
||||
### BadCutsTarget
|
||||
|
||||
```protobuf
|
||||
BadCutsTarget = 8;
|
||||
```
|
||||
|
||||
Sorts by proximity to a target bad cuts value.
|
||||
Example with target = 40:
|
||||
|
||||
```
|
||||
Player A: 39 → 1
|
||||
Player B: 43 → 3
|
||||
Player C: 28 → 12
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MaxCombo
|
||||
|
||||
```protobuf
|
||||
MaxCombo = 9;
|
||||
```
|
||||
|
||||
Sorts by max combo achieved, descending.
|
||||
|
||||
### MaxComboAscending
|
||||
|
||||
```protobuf
|
||||
MaxComboAscending = 10;
|
||||
```
|
||||
|
||||
Sorts by max combo, ascending.
|
||||
|
||||
### MaxComboTarget
|
||||
|
||||
```protobuf
|
||||
MaxComboTarget = 11;
|
||||
```
|
||||
|
||||
Sorts by proximity to a target max combo value.
|
||||
|
||||
---
|
||||
|
||||
### GoodCuts
|
||||
|
||||
```protobuf
|
||||
GoodCuts = 12;
|
||||
```
|
||||
|
||||
Sorts by number of good cuts, descending.
|
||||
|
||||
### GoodCutsAscending
|
||||
|
||||
```protobuf
|
||||
GoodCutsAscending = 13;
|
||||
```
|
||||
|
||||
Sorts by number of good cuts, ascending.
|
||||
|
||||
### GoodCutsTarget
|
||||
|
||||
```protobuf
|
||||
GoodCutsTarget = 14;
|
||||
```
|
||||
|
||||
Sorts by proximity to a target good cuts value.
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Enum Value | Bit Value | Sort Type | Description |
|
||||
| ---------------------- | --------- | ---------- | ----------------------------------- |
|
||||
| ModifiedScore | 0 | Descending | Standard leaderboard score |
|
||||
| ModifiedScoreAscending | 1 | Ascending | Inverse score order |
|
||||
| ModifiedScoreTarget | 2 | Target | Closeness to target score |
|
||||
| NotesMissed | 3 | Descending | More missed notes = worse |
|
||||
| NotesMissedAscending | 4 | Ascending | Fewer missed notes = better |
|
||||
| NotesMissedTarget | 5 | Target | Closeness to target missed notes |
|
||||
| BadCuts | 6 | Descending | More bad cuts = worse |
|
||||
| BadCutsAscending | 7 | Ascending | Fewer bad cuts = better |
|
||||
| BadCutsTarget | 8 | Target | Closeness to target bad cuts |
|
||||
| MaxCombo | 9 | Descending | Highest combo achieved |
|
||||
| MaxComboAscending | 10 | Ascending | Lowest combo achieved |
|
||||
| MaxComboTarget | 11 | Target | Closeness to target combo count |
|
||||
| GoodCuts | 12 | Descending | Most good cuts = better |
|
||||
| GoodCutsAscending | 13 | Ascending | Fewest good cuts = better |
|
||||
| GoodCutsTarget | 14 | Target | Closeness to target good cuts count |
|
||||
|
||||
|
||||
# New DB fileserver idea:
|
||||
|
||||
4 columns:
|
||||
- uploader (discordid)
|
||||
- file_name (guid identifier)
|
||||
- file (Unit8Array)
|
||||
- created_at (time)
|
||||
|
||||
PostgreSQL:
|
||||
```sql
|
||||
CREATE TABLE files (
|
||||
id SERIAL PRIMARY KEY,
|
||||
uploader TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL UNIQUE,
|
||||
file BYTEA NOT NULL, -- BYTEA stores binary data
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
MySQL:
|
||||
```sql
|
||||
CREATE TABLE files (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
uploader VARCHAR(64) NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL UNIQUE,
|
||||
file LONGBLOB NOT NULL, -- LONGBLOB supports up to 4GB of binary data
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
## Implementation example (using my beloved Drizzle ORM and a postgres database):
|
||||
|
||||
```ts
|
||||
export const files = pgTable("files", {
|
||||
id: serial("id").primaryKey(),
|
||||
uploader: text("uploader").notNull(),
|
||||
fileName: text("file_name").notNull().unique(),
|
||||
file: blob("file").notNull(), // Drizzle handles BYTEA for pg as blob
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
import { db } from "./db"; // drizzle db setup
|
||||
import { files } from "./schema"; // import the table
|
||||
|
||||
const uploadFile = async (uploader: string, fileName: string, binaryData: Uint8Array) => {
|
||||
await db.insert(files).values({
|
||||
uploader,
|
||||
fileName,
|
||||
file: Buffer.from(binaryData), // Convert Uint8Array to Buffer for insertion
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
async function getFile(fileName: string) {
|
||||
const result = await db.select().from(files).where(eq(files.fileName, fileName));
|
||||
if (!result.length) return null;
|
||||
|
||||
const fileRecord = result[0];
|
||||
const uint8Array = new Uint8Array(fileRecord.file); // convert Buffer to Uint8Array
|
||||
return uint8Array;
|
||||
};
|
||||
|
||||
app.get("/files/:fileName", async (req, res) => {
|
||||
const fileName = req.params.fileName;
|
||||
const file = await getFile(fileName);
|
||||
if (!file) return res.status(404).send("File not found");
|
||||
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.send(Buffer.from(file));
|
||||
});
|
||||
|
||||
```
|
||||
|
|
@ -121,7 +121,7 @@
|
|||
<span class="material-icons">code</span>
|
||||
GitHub
|
||||
</a>
|
||||
<a href="http://tournamentassistant.net" target="_blank" rel="noopener noreferrer">
|
||||
<a href="https://tournamentassistant.net" target="_blank" rel="noopener noreferrer">
|
||||
<span class="material-icons">sports_esports</span>
|
||||
Tournament Assistant
|
||||
</a>
|
||||
|
|
@ -168,6 +168,14 @@
|
|||
--border-color: #333333;
|
||||
--navbar-height: 4rem;
|
||||
--footer-bg: #191919;
|
||||
--success-color: #22c55e;
|
||||
--warning-color: #f59e0b;
|
||||
--error-color: #ef4444;
|
||||
|
||||
--comfortable-red: #d9534f;
|
||||
--comfortable-red-hover: #c9302c;
|
||||
--pick-green: #28a745;
|
||||
--pick-green-hover: #218838;
|
||||
}
|
||||
|
||||
/* Global styles */
|
||||
|
|
|
|||
|
|
@ -68,8 +68,8 @@ onMount(() => {
|
|||
<div class="feature-icon">
|
||||
<i class="material-icons">devices</i>
|
||||
</div>
|
||||
<h3>Fully Responsive</h3>
|
||||
<p>Hopefully works seamlessly across all devices, from desktop to mobile. (Although I am very much working...)</p>
|
||||
<h3>Reliable</h3>
|
||||
<p>ShyyTAUI is built to be safe and reliable. This means you don't have to worry about accidentally ending a match, or refreshing when you shouldn't.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
|
|
@ -77,7 +77,7 @@ onMount(() => {
|
|||
<i class="material-icons">code</i>
|
||||
</div>
|
||||
<h3>Build More</h3>
|
||||
<p>Use moons-ta-client to build what you need! Overlays, casting panels, think of anything!</p>
|
||||
<p>Use moons-ta-client to build what you need! This site has the official TournamentAssistant Client documentation.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -17,26 +17,58 @@
|
|||
category: "Getting Started",
|
||||
icon: "rocket_launch",
|
||||
items: [
|
||||
{ title: "Introduction", file: "intro", order: 1 },
|
||||
{ title: "Installation", file: "sample", order: 2 },
|
||||
{ title: "Client", file: "client", order: 3 },
|
||||
{ title: "How TA Works, Info", file: "taInfo", order: 4 }
|
||||
{ title: "Introduction", file: "1-intro", order: 1 },
|
||||
{ title: "Installation", file: "1-installation", order: 2 },
|
||||
{ title: "Familiarisation, States", file: "1-fam-states-intro", order: 3 },
|
||||
{ title: "How TA Works, Info", file: "1-taInfo", order: 4 },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: "Core Concepts",
|
||||
icon: "psychology",
|
||||
category: "State Manager",
|
||||
icon: "account_tree",
|
||||
items: [
|
||||
{ title: "Tournaments", file: "", order: 1 },
|
||||
{ title: "Matches", file: "", order: 2 },
|
||||
{ title: "Players", file: "", order: 3 }
|
||||
{ title: "Introduction to the StateManager", file: "2-stateManager-intro", order: 1 },
|
||||
{ title: "All Functions", file: "2-stateManager-allFunctions", order: 2 },
|
||||
{ title: "Emit", file: "2-stateManager-emit", order: 3 },
|
||||
{ title: "GetKnownServers", file: "2-stateManager-getKnownServers", order: 4 },
|
||||
{ title: "GetMatch", file: "2-stateManager-getMatch", order: 6 },
|
||||
{ title: "GetMatches", file: "2-stateManager-getMatches", order: 7 },
|
||||
{ title: "GetQualifier", file: "2-stateManager-getQualifier", order: 7 },
|
||||
{ title: "GetQualifiers", file: "2-stateManager-getQualifiers", order: 8 },
|
||||
{ title: "GetSelfGuid", file: "2-stateManager-getSelfGuid", order: 9 },
|
||||
{ title: "GetTournament", file: "2-stateManager-getTournament", order: 10 },
|
||||
{ title: "GetTournaments", file: "2-stateManager-getTournaments", order: 11 },
|
||||
{ title: "GetUser", file: "2-stateManager-getUser", order: 12 },
|
||||
{ title: "GetUsers", file: "2-stateManager-getUsers", order: 13 },
|
||||
{ title: "HandlePacket", file: "2-stateManager-handlePacket", order: 14 },
|
||||
{ title: "On", file: "2-stateManager-on", order: 15 },
|
||||
{ title: "Once", file: "2-stateManager-once", order: 16 },
|
||||
{ title: "RemoveListener", file: "2-stateManager-removeListener", order: 17 },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: "API Reference",
|
||||
category: "Client",
|
||||
icon: "api",
|
||||
items: [
|
||||
{ title: "Authentication", file: "", order: 1 },
|
||||
{ title: "3-", file: "", order: 1 },
|
||||
{ title: "Endpoints", file: "", order: 2 },
|
||||
{ title: "Response Types", file: "", order: 3 }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: "Models",
|
||||
icon: "view_in_ar",
|
||||
items: [
|
||||
{ title: "4-", file: "", order: 1 },
|
||||
{ title: "Endpoints", file: "", order: 2 },
|
||||
{ title: "Response Types", file: "", order: 3 }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: "Enums",
|
||||
icon: "view_in_ar",
|
||||
items: [
|
||||
{ title: "5-", file: "", order: 1 },
|
||||
{ title: "Endpoints", file: "", order: 2 },
|
||||
{ title: "Response Types", file: "", order: 3 }
|
||||
]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { onMount, onDestroy } from "svelte";
|
||||
import { bkAPIUrl } from '$lib/config.json';
|
||||
//@ts-ignore
|
||||
import { Match, Tournament, TAClient, Response_ResponseType, User, User_PlayStates } from 'moons-ta-client';
|
||||
import { Match, Tournament, TAClient, Response_ResponseType, User, User_PlayStates, User_ClientTypes } from 'moons-ta-client';
|
||||
import { writable } from "svelte/store";
|
||||
import SideMenu from "$lib/components/menus/SideMenuTournaments.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
|
|
@ -153,8 +153,11 @@
|
|||
}
|
||||
|
||||
async function handleUserConnected(params: [User, Tournament]) {
|
||||
if(params[1].guid == tournament?.guid) {
|
||||
availablePlayers.push(params[0]);
|
||||
if(params[1].guid == tournament?.guid && params[0].clientType == User_ClientTypes.Player) {
|
||||
availablePlayers = [
|
||||
...availablePlayers,
|
||||
params[0]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -447,7 +447,7 @@
|
|||
<EditMapModal
|
||||
tournamentGuid={tournamentGuid}
|
||||
poolGuid={poolGuid}
|
||||
map={maps.find(map => map.taData.guid == currentEditMap?.guid)}
|
||||
map={maps.find(map => map.taData.guid == currentEditMap?.guid) || null}
|
||||
on:close={closeEditMapModal}
|
||||
on:mapUpdated={handleMapUpdated}
|
||||
on:mapAdded={handleMapAdded}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,41 @@
|
|||
import { bkAPIUrl } from '$lib/config.json';
|
||||
import Notification from '$lib/components/notifications/Popup.svelte';
|
||||
//@ts-ignore
|
||||
import { Match, Tournament, TAClient, Response_ResponseType, User } from 'moons-ta-client';
|
||||
import { Match, Tournament, TAClient, Response_ResponseType, User, RealtimeScore, Push_SongFinished, Map, User_DownloadStates, User_PlayStates } from 'moons-ta-client';
|
||||
import { writable } from "svelte/store";
|
||||
import { goto } from "$app/navigation";
|
||||
import { fetchMapByLevelId } from "$lib/services/beatsaver.js";
|
||||
import { fetchMapByLevelId, type BeatSaverMap } from "$lib/services/beatsaver.js";
|
||||
import SongQueue from "$lib/components/modules/SongQueue.svelte";
|
||||
import EditMap from "$lib/components/popups/EditMap.svelte";
|
||||
import Popup from "$lib/components/notifications/Popup.svelte";
|
||||
import PreviouslyPlayedSongs from "$lib/components/modules/PreviouslyPlayedSongs.svelte";
|
||||
import { xml } from "d3";
|
||||
|
||||
export let data;
|
||||
|
||||
interface CustomMap {
|
||||
taData: Map,
|
||||
beatsaverData: BeatSaverMap
|
||||
}
|
||||
|
||||
interface RealTimeScoreForPlayers {
|
||||
player: User,
|
||||
recentScore: RealtimeScore
|
||||
}
|
||||
|
||||
interface ScoreWithAccuracy extends Push_SongFinished {
|
||||
accuracy: number;
|
||||
}
|
||||
|
||||
interface PreviousResults {
|
||||
taData: Map;
|
||||
beatsaverData: BeatSaverMap;
|
||||
scores: ScoreWithAccuracy[];
|
||||
completionType: 'Completed' | 'Still Awaiting Scores' | 'Exited To Menu';
|
||||
}
|
||||
|
||||
let upcomingQueueMaps: CustomMap[] = [];
|
||||
|
||||
const tournamentGuid: string = data.tournamentGuid;
|
||||
const matchGuid: string = data.matchGuid;
|
||||
|
||||
|
|
@ -20,29 +48,54 @@
|
|||
let error: string | null = null;
|
||||
let matchPlayers: User[] = [];
|
||||
let currentSong: any = null;
|
||||
let currentSongData: any = null;
|
||||
let currentSongData: BeatSaverMap;
|
||||
let isMatchActive = false;
|
||||
let streamSyncEnabled = false;
|
||||
|
||||
let realTimeScores: RealTimeScoreForPlayers[] = [];
|
||||
let previousMatchResults: PreviousResults[] = [];
|
||||
|
||||
const colorPresets = {
|
||||
primary: 'var(--accent-glow)',
|
||||
secondary: 'var(--text-secondary)',
|
||||
success: '#4CAF50',
|
||||
warning: '#FFC107',
|
||||
error: '#F44336',
|
||||
info: '#2196F3',
|
||||
default: 'var(--text-primary)'
|
||||
};
|
||||
type ColorPreset = keyof typeof colorPresets;
|
||||
|
||||
type ButtonType = 'primary' | 'secondary' | 'text' | 'outlined';
|
||||
interface ButtonConfig {
|
||||
text: string;
|
||||
type: ButtonType;
|
||||
href?: string;
|
||||
color?: ColorPreset;
|
||||
icon?: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
// Modal states
|
||||
let showKickConfirmModal = false;
|
||||
let showBackToMenuModal = false;
|
||||
let showExitMatchModal = false;
|
||||
let playerToKick: User | null = null;
|
||||
let showCannotExitPage: boolean = false;
|
||||
|
||||
// Player status enum
|
||||
enum PlayerStatus {
|
||||
Downloading = "downloading",
|
||||
SelectingSong = "selecting",
|
||||
Idle = "idle"
|
||||
}
|
||||
let modalMessage: string = "";
|
||||
let modalIcon: string = "danger";
|
||||
let modalIconColor: ColorPreset = 'error';
|
||||
let modalButtons: ButtonConfig[] = [];
|
||||
let modalCustomImage: string = "";
|
||||
let modalAutoClose: number = 0; // no auto close
|
||||
|
||||
enum PlayerPlayState {
|
||||
In_Menu = 0,
|
||||
Waiting_For_Coordinator = 1,
|
||||
In_Game = 2
|
||||
}
|
||||
let showMapModal: boolean = false;
|
||||
let currentlyEditingMap: CustomMap | null = null;
|
||||
|
||||
let loadingSongForPlayers: boolean = false;
|
||||
let failedToLoadSongForPlayers: boolean = false;
|
||||
|
||||
const activeSongPlayers = new Set<`${string}-${string}`>(); // Format: "userGuid-levelId"
|
||||
|
||||
// Remove 'Bearer ' from the token
|
||||
if($authTokenStore) {
|
||||
|
|
@ -50,10 +103,7 @@
|
|||
client.setAuthToken(cleanToken);
|
||||
}
|
||||
|
||||
// Check if any player is currently playing
|
||||
function isAnyPlayerPlaying(): boolean {
|
||||
return matchPlayers.some(player => (player.playState as any) === PlayerPlayState.In_Game);
|
||||
}
|
||||
$: isAnyPlayerPlaying = matchPlayers.some(player => (player.playState as any) === User_PlayStates.InGame);
|
||||
|
||||
// Show notification function (you mentioned you have this)
|
||||
function showNotification(message: string, type: 'warning' | 'error' | 'success' = 'warning') {
|
||||
|
|
@ -62,19 +112,56 @@
|
|||
}
|
||||
|
||||
// Handle back to matches with confirmation if needed
|
||||
async function handleBackToMatches() {
|
||||
if (isAnyPlayerPlaying()) {
|
||||
showExitMatchModal = true;
|
||||
} else {
|
||||
// await client.deleteMatch(tournamentGuid, matchGuid);
|
||||
async function handleBackToMatches(endMatch: boolean) {
|
||||
if(match!.leader !== client.stateManager.getSelfGuid()) {
|
||||
await removeSelfFromMatch();
|
||||
goto(`/tournaments/${tournamentGuid}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle back to matches with confirmation if needed
|
||||
async function handleEndMatch() {
|
||||
await client.deleteMatch(tournamentGuid, matchGuid);
|
||||
goto(`/tournaments/${tournamentGuid}`);
|
||||
if(endMatch) {
|
||||
if (isAnyPlayerPlaying) {
|
||||
showExitMatchModal = true;
|
||||
modalMessage = `Players are currently in-game. Exiting will close the match for all players. Continue?`;
|
||||
modalIcon = 'warning';
|
||||
modalButtons = [
|
||||
{
|
||||
text: `Close (NO)`,
|
||||
type: 'primary',
|
||||
action: () => showBackToMenuModal = false,
|
||||
icon: 'close'
|
||||
},
|
||||
{
|
||||
text: `Yes (YES)`,
|
||||
type: 'secondary',
|
||||
action: async() => await confirmExitMatch(),
|
||||
icon: 'backspace'
|
||||
}
|
||||
];
|
||||
} else {
|
||||
await removeSelfFromMatch();
|
||||
await client.deleteMatch(tournamentGuid, matchGuid);
|
||||
goto(`/tournaments/${tournamentGuid}`);
|
||||
}
|
||||
}
|
||||
|
||||
showExitMatchModal = true;
|
||||
modalMessage = `This should not cause issues as it should not end the match. But TA can be weird sometimes. THIS SHOULD BE SAFE. Do you want to continue?`;
|
||||
modalIcon = 'info';
|
||||
modalIconColor = 'info';
|
||||
modalButtons = [
|
||||
{
|
||||
text: `Close (NO)`,
|
||||
type: 'primary',
|
||||
action: () => showBackToMenuModal = false,
|
||||
icon: 'close'
|
||||
},
|
||||
{
|
||||
text: `Yes (YES)`,
|
||||
type: 'secondary',
|
||||
action: async() => await confirmExitMatch(),
|
||||
icon: 'backspace'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Confirm exit match
|
||||
|
|
@ -87,12 +174,31 @@
|
|||
// Handle kick player
|
||||
function handleKickPlayer(player: User) {
|
||||
playerToKick = player;
|
||||
modalMessage = `Are you sure you want to kick ${player.name} from the match?`;
|
||||
modalIcon = 'warning';
|
||||
modalButtons = [
|
||||
{
|
||||
text: `Close (NO)`,
|
||||
type: 'primary',
|
||||
action: () => showKickConfirmModal = false,
|
||||
icon: 'close'
|
||||
},
|
||||
{
|
||||
text: `Kick ${player.name} (YES)`,
|
||||
type: 'secondary',
|
||||
action: async() => await confirmKickPlayer(),
|
||||
icon: 'sports_martial_arts'
|
||||
}
|
||||
];
|
||||
showKickConfirmModal = true;
|
||||
}
|
||||
|
||||
// Confirm kick player
|
||||
async function confirmKickPlayer() {
|
||||
if (!playerToKick) return;
|
||||
if (!playerToKick) {
|
||||
showKickConfirmModal = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Implementation for kicking player from match
|
||||
|
|
@ -117,7 +223,6 @@
|
|||
}
|
||||
|
||||
await startSongForMatch();
|
||||
showNotification('Song started for all players', 'success');
|
||||
isMatchActive = true;
|
||||
} catch (err) {
|
||||
console.error('Error starting song:', err);
|
||||
|
|
@ -142,6 +247,22 @@
|
|||
// Back to menu
|
||||
function handleBackToMenu() {
|
||||
showBackToMenuModal = true;
|
||||
modalMessage = `This will send all players back to the menu and stop the current song. Continue?`;
|
||||
modalIcon = 'warning';
|
||||
modalButtons = [
|
||||
{
|
||||
text: `Close (NO)`,
|
||||
type: 'primary',
|
||||
action: () => showBackToMenuModal = false,
|
||||
icon: 'close'
|
||||
},
|
||||
{
|
||||
text: `Yes (YES)`,
|
||||
type: 'secondary',
|
||||
action: async() => await confirmBackToMenu(),
|
||||
icon: 'backspace'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Confirm back to menu
|
||||
|
|
@ -160,29 +281,27 @@
|
|||
}
|
||||
|
||||
// Return status color based on player status
|
||||
function getStatusColor(playState: any): string {
|
||||
function getStatusColor(playState: User_PlayStates, downloadState: User_DownloadStates): string {
|
||||
if(downloadState == User_DownloadStates.Downloading) return "var(--danger-color)";
|
||||
if(downloadState == User_DownloadStates.DownloadError) return "var(--danger-color)";
|
||||
if(playState == User_PlayStates.InGame) return "var(--danger-color)";
|
||||
|
||||
switch (playState) {
|
||||
case PlayerPlayState.In_Game:
|
||||
return "var(--danger-color)";
|
||||
case PlayerPlayState.Waiting_For_Coordinator:
|
||||
case User_PlayStates.WaitingForCoordinator:
|
||||
return "#FCD34D"; // Yellow
|
||||
case PlayerPlayState.In_Menu:
|
||||
case User_PlayStates.InMenu:
|
||||
return "#10B981"; // Green
|
||||
default:
|
||||
return "var(--text-secondary)";
|
||||
}
|
||||
}
|
||||
|
||||
// Get status text
|
||||
function getStatusText(playState: any): string {
|
||||
return PlayerPlayState[playState].replace('_', ' ');
|
||||
}
|
||||
|
||||
// Handle match events
|
||||
function handleMatchUpdated(params: any) {
|
||||
async function handleMatchUpdated(params: [Match, Tournament]) {
|
||||
console.log("Match updated:", params);
|
||||
if (params.details.updateMatch.match.guid === matchGuid) {
|
||||
fetchMatchData();
|
||||
if (params[0].guid === matchGuid) {
|
||||
match = params[0];
|
||||
await refreshMatchDetails(params[0]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -212,6 +331,27 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function refreshMatchDetails(match: Match) {
|
||||
// Get match players
|
||||
matchPlayers = match.associatedUsers
|
||||
.map(userGuid => client.stateManager.getUser(tournamentGuid, userGuid))
|
||||
.filter(user => user && user.clientType === 0) as User[];
|
||||
|
||||
// Get current song if available
|
||||
currentSong = match.selectedMap?.gameplayParameters || null;
|
||||
if(currentSong) {
|
||||
try {
|
||||
const tempMapData = await fetchMapByLevelId(currentSong.beatmap.levelId);
|
||||
|
||||
if(tempMapData) {
|
||||
currentSongData = tempMapData;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Current song beatsaver data fetch failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMatchData() {
|
||||
if (!$authTokenStore) {
|
||||
window.location.href = '/discordAuth';
|
||||
|
|
@ -234,22 +374,15 @@
|
|||
tournament = client.stateManager.getTournament(tournamentGuid);
|
||||
match = client.stateManager.getMatch(tournamentGuid, matchGuid);
|
||||
|
||||
console.log("match", match)
|
||||
|
||||
if(!match?.associatedUsers.includes(client.stateManager.getSelfGuid())) {
|
||||
await addSelfToMatch();
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
throw new Error('Match not found');
|
||||
}
|
||||
|
||||
// Get match players
|
||||
matchPlayers = match.associatedUsers
|
||||
.map(userGuid => client.stateManager.getUser(tournamentGuid, userGuid))
|
||||
.filter(user => user && user.clientType === 0) as User[];
|
||||
|
||||
// Get current song if available
|
||||
currentSong = match.selectedMap?.gameplayParameters?.beatmap || null;
|
||||
if(currentSong) {
|
||||
currentSongData = await fetchMapByLevelId(currentSong.levelId);
|
||||
}
|
||||
await refreshMatchDetails(match);
|
||||
} catch (err) {
|
||||
console.error('Error fetching match data:', err);
|
||||
error = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
|
|
@ -265,8 +398,23 @@
|
|||
}
|
||||
|
||||
async function startSongForMatch() {
|
||||
// const response = await client.playSong()
|
||||
console.log('Starting song for match');
|
||||
if (!currentSong) return;
|
||||
|
||||
const levelId = currentSong.beatmap.levelId;
|
||||
const playingPlayerIds = matchPlayers.map(player => player.guid);
|
||||
|
||||
// Clear any existing entries for this levelId
|
||||
Array.from(activeSongPlayers)
|
||||
.filter(key => key.endsWith(`-${levelId}`))
|
||||
.forEach(key => activeSongPlayers.delete(key));
|
||||
|
||||
// Add all current players for this song
|
||||
playingPlayerIds.forEach(playerGuid => {
|
||||
activeSongPlayers.add(`${playerGuid}-${levelId}`);
|
||||
});
|
||||
|
||||
const response = await client.playSong(currentSong, playingPlayerIds);
|
||||
console.log('startMap', response);
|
||||
}
|
||||
|
||||
async function startSongWithStreamSyncForMatch() {
|
||||
|
|
@ -275,8 +423,8 @@
|
|||
}
|
||||
|
||||
async function sendPlayersBackToMenu() {
|
||||
// Implement back to menu logic
|
||||
console.log('Sending players back to menu');
|
||||
const backToMenuResponse = await client.returnToMenu(matchPlayers.filter(x => x.playState == User_PlayStates.InGame).map(x => x.guid));
|
||||
console.log("Sent players back to menu!", backToMenuResponse)
|
||||
}
|
||||
|
||||
async function handleUserDisconnected(params: [User, Tournament]) {
|
||||
|
|
@ -286,6 +434,226 @@
|
|||
}
|
||||
}
|
||||
|
||||
function getTimeStringFromSeconds(seconds: number) {
|
||||
let newString: string = "";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
|
||||
if(minutes > 0) {
|
||||
newString += `${minutes} minutes `;
|
||||
}
|
||||
|
||||
const timeSeconds = (seconds - Math.floor(seconds / 60) * 60).toFixed(1);
|
||||
|
||||
if(!timeSeconds.startsWith("0")) {
|
||||
newString += `${timeSeconds} seconds `;
|
||||
}
|
||||
return newString;
|
||||
}
|
||||
|
||||
async function handleRealtimeScoreUpdate(rts: RealtimeScore) {
|
||||
console.log(`Player ${matchPlayers.find(x => x.guid == rts.userGuid)?.name}: ${rts.accuracy}`, rts);
|
||||
|
||||
const player = matchPlayers.find(x => x.guid === rts.userGuid)!;
|
||||
|
||||
const index = realTimeScores.findIndex(x => x.player.guid === rts.userGuid);
|
||||
|
||||
if (index !== -1) {
|
||||
// Update the existing entry
|
||||
realTimeScores[index] = { player, recentScore: rts };
|
||||
} else {
|
||||
// Append only if it's a new player
|
||||
realTimeScores = [
|
||||
...realTimeScores,
|
||||
{ player, recentScore: rts }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function getPlayerRtsScore(userGuid: string) {
|
||||
let playerScore = realTimeScores.find(x => x.player.guid == userGuid);
|
||||
if(!playerScore) {
|
||||
playerScore = {
|
||||
player: matchPlayers.find(x => x.guid == userGuid)!,
|
||||
recentScore: {
|
||||
userGuid: userGuid,
|
||||
score: 0,
|
||||
scoreWithModifiers: 0,
|
||||
maxScore: 0,
|
||||
maxScoreWithModifiers: 0,
|
||||
combo: 0,
|
||||
playerHealth: 0,
|
||||
accuracy: 0,
|
||||
songPosition: 0,
|
||||
notesMissed: 0,
|
||||
badCuts: 0,
|
||||
bombHits: 0,
|
||||
wallHits: 0,
|
||||
maxCombo: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return playerScore;
|
||||
}
|
||||
|
||||
async function handleSongFinished(rts: Push_SongFinished) {
|
||||
if (rts.matchId !== matchGuid) return;
|
||||
|
||||
const levelId = rts.beatmap?.levelId;
|
||||
if (!levelId) return;
|
||||
|
||||
// Remove player from active song tracking
|
||||
activeSongPlayers.delete(`${rts.player!.guid}-${levelId}`);
|
||||
|
||||
const map = upcomingQueueMaps.find(x =>
|
||||
x.taData.gameplayParameters!.beatmap!.levelId.toUpperCase() === levelId.toUpperCase()
|
||||
);
|
||||
|
||||
if (!map) {
|
||||
console.log('Received a score for unknown map', rts);
|
||||
return;
|
||||
}
|
||||
|
||||
const newRts: ScoreWithAccuracy = {...rts, accuracy: 0};
|
||||
|
||||
// Check if any players are still playing this song
|
||||
const playersStillPlaying = Array.from(activeSongPlayers)
|
||||
.some(key => key.endsWith(`-${levelId}`));
|
||||
|
||||
const completionType = playersStillPlaying
|
||||
? 'Still Awaiting Scores'
|
||||
: 'Completed';
|
||||
|
||||
// Find or create the result record
|
||||
const existingRecordIndex = previousMatchResults.findIndex(x =>
|
||||
x.completionType === 'Still Awaiting Scores' &&
|
||||
x.taData.gameplayParameters?.beatmap?.levelId.toUpperCase() === levelId.toUpperCase()
|
||||
);
|
||||
|
||||
if (existingRecordIndex === -1) {
|
||||
previousMatchResults = [
|
||||
{
|
||||
taData: map.taData,
|
||||
beatsaverData: map.beatsaverData,
|
||||
completionType,
|
||||
scores: [newRts]
|
||||
},
|
||||
...previousMatchResults
|
||||
];
|
||||
} else {
|
||||
const existingRecord = previousMatchResults[existingRecordIndex];
|
||||
previousMatchResults = [
|
||||
{
|
||||
taData: map.taData,
|
||||
beatsaverData: map.beatsaverData,
|
||||
completionType,
|
||||
scores: [...existingRecord.scores, newRts]
|
||||
},
|
||||
...previousMatchResults.filter((_, i) => i !== existingRecordIndex)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMapUpdated(event: CustomEvent) {
|
||||
const map = upcomingQueueMaps.find(x => x.taData.guid == event.detail.map.guid);
|
||||
if(!map) return;
|
||||
upcomingQueueMaps = [
|
||||
...upcomingQueueMaps.filter(x => x.taData.guid !== event.detail.map.guid),
|
||||
{
|
||||
taData: (event.detail.map as Map),
|
||||
beatsaverData: map.beatsaverData
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
async function handleMapAdded(event: CustomEvent) {
|
||||
const beatsaverData: BeatSaverMap | null = await fetchMapByLevelId(event.detail.map.gameplayParameters.beatmap.levelId);
|
||||
if(!beatsaverData) {
|
||||
console.log('Failed to fetch beatsaver map data for new map, aborting map addition!');
|
||||
return;
|
||||
}
|
||||
const newMap: CustomMap = {
|
||||
taData: event.detail.map,
|
||||
beatsaverData: beatsaverData
|
||||
};
|
||||
|
||||
upcomingQueueMaps = [
|
||||
...upcomingQueueMaps,
|
||||
newMap
|
||||
];
|
||||
}
|
||||
|
||||
function closeEditMapModal() {
|
||||
showMapModal = false;
|
||||
}
|
||||
|
||||
async function handleAddMap() {
|
||||
currentlyEditingMap = null;
|
||||
showMapModal = true;
|
||||
}
|
||||
|
||||
async function handleEditMap(event: CustomEvent) {
|
||||
currentlyEditingMap = event.detail.map;
|
||||
showMapModal = true;
|
||||
}
|
||||
|
||||
async function handleRemoveMapFromQueue(event: CustomEvent) {
|
||||
upcomingQueueMaps = [
|
||||
...upcomingQueueMaps.filter(x => x.taData.guid !== event.detail.map.taData.guid)
|
||||
];
|
||||
}
|
||||
|
||||
async function handleLoadMap(event: CustomEvent) {
|
||||
const map: CustomMap = event.detail.map;
|
||||
loadingSongForPlayers = false;
|
||||
failedToLoadSongForPlayers = false;
|
||||
|
||||
let playingPlayerIds = matchPlayers
|
||||
.filter(player => player.playState !== User_PlayStates.InGame)
|
||||
.map(player => player.guid);
|
||||
|
||||
if(!playingPlayerIds || playingPlayerIds.length == 0) {
|
||||
playingPlayerIds = [];
|
||||
}
|
||||
|
||||
loadingSongForPlayers = true;
|
||||
const matchResponse = await client.setMatchMap(tournamentGuid, matchGuid, map.taData);
|
||||
if(matchResponse.type == Response_ResponseType.Fail) return;
|
||||
const loadResponse = await client.loadSong(map.taData.gameplayParameters!.beatmap!.levelId, playingPlayerIds, 3 * 60 * 1000);
|
||||
failedToLoadSongForPlayers = false;
|
||||
|
||||
const failedLoads = loadResponse.filter(x => x.response.type == Response_ResponseType.Fail);
|
||||
|
||||
if(failedLoads.length > 0) {
|
||||
failedToLoadSongForPlayers = true;
|
||||
showNotification('Failed to load song as one or more players failed to load! Please try again', 'error');
|
||||
} else {
|
||||
loadingSongForPlayers = false;
|
||||
showNotification('Successfully loaded song!', 'success');
|
||||
}
|
||||
|
||||
console.log("songLoaded", loadResponse);
|
||||
}
|
||||
|
||||
async function handleUserUpdated(params: [User, Tournament]) {
|
||||
if(params[1].guid !== tournamentGuid) return;
|
||||
|
||||
const userIndex = matchPlayers.findIndex(x => x.guid === params[0].guid);
|
||||
if(userIndex !== -1) {
|
||||
matchPlayers = [
|
||||
...matchPlayers.slice(0, userIndex),
|
||||
params[0],
|
||||
...matchPlayers.slice(userIndex + 1)
|
||||
];
|
||||
} else {
|
||||
matchPlayers = [...matchPlayers, params[0]];
|
||||
}
|
||||
}
|
||||
|
||||
const beforeUnloadHandler = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault(); // Not necessary in some browsers but safe
|
||||
};
|
||||
|
||||
onMount(async() => {
|
||||
if ($authTokenStore) {
|
||||
// Fetch the match data that we need to display
|
||||
|
|
@ -293,7 +661,11 @@
|
|||
// Using stateManger listen to global changes to our match
|
||||
client.stateManager.on('matchDeleted', handleMatchDeleted);
|
||||
client.stateManager.on('matchUpdated', handleMatchUpdated);
|
||||
client.stateManager.on('userDisconnected', handleUserDisconnected)
|
||||
client.stateManager.on('userDisconnected', handleUserDisconnected);
|
||||
client.stateManager.on('userUpdated', handleUserUpdated);
|
||||
client.on('realtimeScore', handleRealtimeScoreUpdate);
|
||||
client.on('songFinished', handleSongFinished);
|
||||
window.addEventListener('beforeunload', beforeUnloadHandler);
|
||||
} else {
|
||||
window.location.href = "/discordAuth"
|
||||
}
|
||||
|
|
@ -304,6 +676,11 @@
|
|||
client.stateManager.removeListener('matchUpdated', handleMatchUpdated);
|
||||
client.stateManager.removeListener('matchDeleted', handleMatchDeleted);
|
||||
client.stateManager.removeListener('userDisconnected', handleUserDisconnected);
|
||||
client.stateManager.removeListener('userUpdated', handleUserUpdated);
|
||||
client.removeListener('realtimeScore', handleRealtimeScoreUpdate);
|
||||
client.removeListener('songFinished', handleSongFinished);
|
||||
|
||||
window.removeEventListener('beforeunload', beforeUnloadHandler);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -313,17 +690,17 @@
|
|||
<div class="content-header">
|
||||
<div class="header-left">
|
||||
<button
|
||||
class="back-button {isAnyPlayerPlaying() ? 'disabled' : ''}"
|
||||
on:click={handleBackToMatches}
|
||||
disabled={isAnyPlayerPlaying()}
|
||||
class="back-button {isAnyPlayerPlaying ? 'disabled' : ''}"
|
||||
on:click={() => handleBackToMatches(false)}
|
||||
disabled={isAnyPlayerPlaying}
|
||||
>
|
||||
<span class="material-icons">arrow_back</span>
|
||||
Back to Matches
|
||||
{#if isAnyPlayerPlaying()}
|
||||
{#if isAnyPlayerPlaying}
|
||||
<span class="material-icons warning-icon">warning</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="kick-button" on:click={handleBackToMatches}>
|
||||
<button class="kick-button" on:click={() => handleBackToMatches(true)}>
|
||||
<span class="material-icons">person_remove</span>
|
||||
End Match
|
||||
</button>
|
||||
|
|
@ -361,7 +738,7 @@
|
|||
<img src={currentSongData.versions[0].coverURL || '/default-song-cover.png'} alt="Song Cover" />
|
||||
</div>
|
||||
<div class="song-info">
|
||||
<h4>{currentSong.name || 'Unknown Song'}</h4>
|
||||
<h4>{currentSong.beatmap.name || 'Unknown Song'}</h4>
|
||||
<p>{currentSongData.metadata.songAuthorName || 'Unknown Artist'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -371,6 +748,33 @@
|
|||
<p>No song selected</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if failedToLoadSongForPlayers}
|
||||
<div class="warning-box">
|
||||
<span class="material-icons">warning</span>
|
||||
<div>
|
||||
<strong>FAILED TO LOAD SONG FOR ALL PLAYERS!</strong> You can still start the map, but it will not start for those whos state is "Downloading"!
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loadingSongForPlayers}
|
||||
<div class="warning-box">
|
||||
<span class="material-icons">warning</span>
|
||||
<div>
|
||||
<strong>LOADING SONG FOR PLAYERS!</strong> You can still start the map, but it will not start for those whos state is "Downloading"!
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isAnyPlayerPlaying}
|
||||
<div class="warning-box">
|
||||
<span class="material-icons">warning</span>
|
||||
<div>
|
||||
<strong>PLAYERS ARE IN GAME!</strong> Some players are currently in game. Please refrain from loading new songs, starting maps, or exiting this match. You may of course add new maps to the queue etc.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="song-controls">
|
||||
<button class="action-button start-song" on:click={startSong} disabled={!currentSong}>
|
||||
|
|
@ -385,7 +789,7 @@
|
|||
<span class="material-icons">sync</span>
|
||||
Start with Stream Sync
|
||||
</button>
|
||||
{#if isMatchActive}
|
||||
{#if isAnyPlayerPlaying}
|
||||
<button class="action-button back-to-menu" on:click={handleBackToMenu}>
|
||||
<span class="material-icons">home</span>
|
||||
Back to Menu
|
||||
|
|
@ -394,6 +798,34 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Real Time Score Display -->
|
||||
{#if matchPlayers.some(player => (player.playState) === User_PlayStates.InGame)}
|
||||
<div class="rts-section">
|
||||
<div class="players-list">
|
||||
{#each realTimeScores as rtsPlayer}
|
||||
<div class="player-card">
|
||||
<div class="player-status" style="background-color: {getStatusColor(rtsPlayer.player.playState, rtsPlayer.player.downloadState)}"></div>
|
||||
<img
|
||||
src={rtsPlayer.player.discordInfo?.avatarUrl || "https://api.dicebear.com/7.x/identicon/svg?seed=" + rtsPlayer.player.guid}
|
||||
alt="Player"
|
||||
class="player-avatar"
|
||||
/>
|
||||
<div class="player-info">
|
||||
<h4>{rtsPlayer.player.name} ({rtsPlayer.player.discordInfo?.username || 'Discord Unknown'})</h4>
|
||||
<p class="player-status-text">{(rtsPlayer.recentScore.accuracy * 100).toFixed(2)}%</p>
|
||||
<p class="player-status-text">{rtsPlayer.recentScore.notesMissed} Misses - {rtsPlayer.recentScore.badCuts} Bad Cuts - {rtsPlayer.recentScore.bombHits} Bomb Hits - {rtsPlayer.recentScore.wallHits} Wall Hits</p>
|
||||
</div>
|
||||
<div class="player-info">
|
||||
<h4>Song Progress: {((rtsPlayer.recentScore.songPosition / currentSongData.metadata.duration) * 100).toFixed(2)}%</h4>
|
||||
<p>{getTimeStringFromSeconds(rtsPlayer.recentScore.songPosition)} of {getTimeStringFromSeconds(currentSongData.metadata.duration)}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Players Section -->
|
||||
<div class="players-section">
|
||||
<h3>Players ({matchPlayers.length})</h3>
|
||||
|
|
@ -406,7 +838,7 @@
|
|||
<div class="players-list">
|
||||
{#each matchPlayers as player}
|
||||
<div class="player-card">
|
||||
<div class="player-status" style="background-color: {getStatusColor(player.playState)}"></div>
|
||||
<div class="player-status" style="background-color: {getStatusColor(player.playState, player.downloadState)}"></div>
|
||||
<img
|
||||
src={player.discordInfo?.avatarUrl || "https://api.dicebear.com/7.x/identicon/svg?seed=" + player.guid}
|
||||
alt="Player"
|
||||
|
|
@ -414,7 +846,8 @@
|
|||
/>
|
||||
<div class="player-info">
|
||||
<h4>{player.name} ({player.discordInfo?.username || 'Unknown'})</h4>
|
||||
<p class="player-status-text">{getStatusText(player.playState)}</p>
|
||||
<p class="player-status-text">{User_DownloadStates[player.downloadState]}</p>
|
||||
<p class="player-status-text">{User_PlayStates[player.playState]}</p>
|
||||
</div>
|
||||
<button class="kick-button" on:click={() => handleKickPlayer(player)}>
|
||||
<span class="material-icons">person_remove</span>
|
||||
|
|
@ -425,13 +858,22 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Future: Song Queue Section -->
|
||||
<!-- Song Upcoming Queue Section -->
|
||||
<div class="queue-section">
|
||||
<h3>Song Queue</h3>
|
||||
<div class="empty-state">
|
||||
<span class="material-icons">queue_music</span>
|
||||
<p>Queue feature coming soon</p>
|
||||
</div>
|
||||
<SongQueue
|
||||
bind:maps={upcomingQueueMaps}
|
||||
on:addMap={handleAddMap}
|
||||
on:editMap={handleEditMap}
|
||||
on:removeMap={handleRemoveMapFromQueue}
|
||||
on:songLoad={handleLoadMap}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Previously Played Maps Section -->
|
||||
<div class="queue-section">
|
||||
<PreviouslyPlayedSongs
|
||||
maps={previousMatchResults}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -439,74 +881,28 @@
|
|||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Kick Player Confirmation Modal -->
|
||||
{#if showKickConfirmModal}
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="modal-overlay" on:click={() => showKickConfirmModal = false}>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="modal" on:click|stopPropagation>
|
||||
<div class="modal-header">
|
||||
<span class="material-icons warning">warning</span>
|
||||
<h3>Kick Player</h3>
|
||||
</div>
|
||||
<div class="modal-content">
|
||||
<p>Are you sure you want to kick <strong>{playerToKick?.name}</strong> from the match?</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-button cancel" on:click={() => showKickConfirmModal = false}>Cancel</button>
|
||||
<button class="modal-button confirm" on:click={confirmKickPlayer}>Kick Player</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if showMapModal}
|
||||
<EditMap
|
||||
tournamentGuid={tournamentGuid}
|
||||
map={currentlyEditingMap}
|
||||
on:close={closeEditMapModal}
|
||||
on:mapUpdated={handleMapUpdated}
|
||||
on:mapAdded={handleMapAdded}
|
||||
mode='playing'
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Back to Menu Confirmation Modal -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
{#if showBackToMenuModal}
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="modal-overlay" on:click={() => showBackToMenuModal = false}>
|
||||
<div class="modal" on:click|stopPropagation>
|
||||
<div class="modal-header">
|
||||
<span class="material-icons warning">warning</span>
|
||||
<h3>Back to Menu</h3>
|
||||
</div>
|
||||
<div class="modal-content">
|
||||
<p>This will send all players back to the menu and stop the current song. Continue?</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-button cancel" on:click={() => showBackToMenuModal = false}>Cancel</button>
|
||||
<button class="modal-button confirm" on:click={confirmBackToMenu}>Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Exit Match Confirmation Modal -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
{#if showExitMatchModal}
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="modal-overlay" on:click={() => showExitMatchModal = false}>
|
||||
<div class="modal" on:click|stopPropagation>
|
||||
<div class="modal-header">
|
||||
<span class="material-icons warning">warning</span>
|
||||
<h3>Exit Match</h3>
|
||||
</div>
|
||||
<div class="modal-content">
|
||||
<p>Players are currently in-game. Exiting will close the match for all players. Continue?</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-button cancel" on:click={() => showExitMatchModal = false}>Cancel</button>
|
||||
<button class="modal-button confirm" on:click={confirmExitMatch}>Exit Match</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<Popup
|
||||
open={showKickConfirmModal || showBackToMenuModal || showExitMatchModal}
|
||||
message={modalMessage}
|
||||
icon={modalIcon}
|
||||
iconColor={modalIconColor}
|
||||
buttons={modalButtons}
|
||||
customImage={modalCustomImage}
|
||||
autoClose={modalAutoClose}
|
||||
/>
|
||||
|
||||
<style>
|
||||
/* Global styles inherited from main stylesheet */
|
||||
|
||||
.match-dashboard {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
|
|
@ -638,6 +1034,10 @@
|
|||
.song-section {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.rts-section {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.players-section {
|
||||
grid-column: 1;
|
||||
|
|
@ -645,10 +1045,14 @@
|
|||
|
||||
.queue-section {
|
||||
grid-column: 2;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 0.75rem;
|
||||
padding-top: 1rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Section Styles */
|
||||
.song-section, .players-section, .queue-section {
|
||||
.song-section, .players-section {
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
|
|
@ -925,6 +1329,17 @@
|
|||
.modal-button.confirm:hover {
|
||||
background-color: var(--danger-hover);
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
background-color: rgba(245, 158, 11, 0.1);
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
/* Material Icons */
|
||||
.material-icons {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
//@ts-ignore
|
||||
import { TAClient, Response_ResponseType, Map, Tournament, QualifierEvent, GameplayModifiers_GameOptions, QualifierEvent_EventSettings } from 'moons-ta-client';
|
||||
import { TAClient, Response_ResponseType, Map, Tournament, QualifierEvent, GameplayModifiers_GameOptions, QualifierEvent_EventSettings, QualifierEvent_LeaderboardSort } from 'moons-ta-client';
|
||||
import { TABotTokenStore, TAServerPort, TAServerUrl, authTokenStore, client } from "$lib/stores";
|
||||
import { bufferToImageUrl, convertImageToUint8Array, linkToUint8Array } from "$lib/services/taImages.js";
|
||||
import SideMenu from "$lib/components/menus/SideMenuTournaments.svelte";
|
||||
|
|
@ -9,10 +9,57 @@
|
|||
import InfoPopup from "$lib/components/notifications/InfoPopup.svelte";
|
||||
import EditMapModal from "$lib/components/popups/EditMap.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { fetchMapsByLevelIds, type BeatSaverMap } from "$lib/services/beatsaver.js";
|
||||
import { saveAs } from 'file-saver';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { fetchMapByHashOrKey, fetchMapsByLevelIds, type BeatSaverMap } from "$lib/services/beatsaver.js";
|
||||
import QualifierLeaderboard from "$lib/components/popups/QualifierLeaderboard.svelte";
|
||||
|
||||
export let data;
|
||||
|
||||
interface CustomMap {
|
||||
taData: Map,
|
||||
beatsaverData: BeatSaverMap
|
||||
}
|
||||
|
||||
interface Score {
|
||||
accuracy: number;
|
||||
badCuts: number;
|
||||
color: string;
|
||||
eventId: string;
|
||||
fullCombo: boolean;
|
||||
goodCuts: number;
|
||||
isPlaceholder: boolean;
|
||||
mapId: string;
|
||||
maxCombo: number;
|
||||
maxPossibleScore: number;
|
||||
modifiedScore: number;
|
||||
multipliedScore: number;
|
||||
notesMissed: number;
|
||||
platformId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
const colorPresets = {
|
||||
primary: 'var(--accent-glow)',
|
||||
secondary: 'var(--text-secondary)',
|
||||
success: '#4CAF50',
|
||||
warning: '#FFC107',
|
||||
error: '#F44336',
|
||||
info: '#2196F3',
|
||||
default: 'var(--text-primary)'
|
||||
};
|
||||
type ColorPreset = keyof typeof colorPresets;
|
||||
|
||||
type ButtonType = 'primary' | 'secondary' | 'text' | 'outlined';
|
||||
interface ButtonConfig {
|
||||
text: string;
|
||||
type: ButtonType;
|
||||
href?: string;
|
||||
color?: ColorPreset;
|
||||
icon?: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
const tournamentGuid: string = data.tournamentGuid;
|
||||
const qualifierGuid: string = data.qualifierGuid;
|
||||
|
||||
|
|
@ -20,10 +67,7 @@
|
|||
let qualifier: QualifierEvent | undefined;
|
||||
let isLoading = false;
|
||||
let error: string | null = null;
|
||||
let maps: {
|
||||
taData: Map,
|
||||
beatsaverData: BeatSaverMap
|
||||
}[] = [];
|
||||
let maps: CustomMap[] = [];
|
||||
|
||||
// Qualifier settings
|
||||
let qualifierName: string = "";
|
||||
|
|
@ -34,7 +78,26 @@
|
|||
let enableDiscordBotLeaderboard: boolean = false;
|
||||
let enableDiscordScoreFeed: boolean = false;
|
||||
let discordChannelId: string = "";
|
||||
let leaderboardSortType: string = "score"; // Placeholder for enum
|
||||
let leaderboardSortType: QualifierEvent_LeaderboardSort = 0;
|
||||
|
||||
// Leaderboard Popup variables
|
||||
let showLeaderboardPopup: boolean = false;
|
||||
let currentlyViewingLeaderboardMap: CustomMap | null;
|
||||
let currentlyViewingLeaderboardMapScores: Score[] = [];
|
||||
|
||||
// Generic Modal default
|
||||
let modalMessage: string = "";
|
||||
let modalIcon: string = "check";
|
||||
let modalIconColor: ColorPreset = 'success';
|
||||
let modalButtons: ButtonConfig[] = [];
|
||||
let modalCustomImage: string = "";
|
||||
let modalAutoClose: number = 0; // no auto close
|
||||
let showGenericModal: boolean = false;
|
||||
|
||||
// Export type definition
|
||||
type ExportType = 'json' | 'csv' | 'excel' | 'txt';
|
||||
let selectedExportType: ExportType = 'json';
|
||||
let showExportDropdown = false;
|
||||
|
||||
enum MapDifficulty {
|
||||
"Easy" = 0,
|
||||
|
|
@ -74,7 +137,6 @@
|
|||
let mapToDelete: string | null = null;
|
||||
let showInfoPopup: boolean = false;
|
||||
let infoPopupContent: string = "";
|
||||
let showSuccessNotification = false;
|
||||
let successMessage = "";
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
|
|
@ -113,7 +175,7 @@
|
|||
}
|
||||
|
||||
function closeNotification() {
|
||||
showSuccessNotification = false;
|
||||
showGenericModal = false;
|
||||
}
|
||||
|
||||
function handleImageUpload() {
|
||||
|
|
@ -149,8 +211,12 @@
|
|||
const setNameResponse = await client.setQualifierName(tournamentGuid, qualifierGuid, qualifierName);
|
||||
const setFlagsResponse = await client.setQualifierFlags(tournamentGuid, qualifierGuid, qualifier.flags);
|
||||
console.log("Qualifier Updated!", setImageResponse, setNameResponse, setFlagsResponse);
|
||||
showSuccessNotification = true;
|
||||
setTimeout(() => showSuccessNotification = false, 2500);
|
||||
|
||||
modalMessage = `Successfully Updated the Qualifier!`;
|
||||
modalIcon = 'check';
|
||||
modalButtons = [];
|
||||
modalAutoClose = 1;
|
||||
showGenericModal = true;
|
||||
} catch (err) {
|
||||
console.error('Error saving qualifier settings:', err);
|
||||
error = err instanceof Error ? err.message : 'Failed to save settings';
|
||||
|
|
@ -160,17 +226,21 @@
|
|||
async function handleMapUpdated(event: CustomEvent) {
|
||||
await client.updateQualifierMap(tournamentGuid, qualifierGuid, event.detail.map);
|
||||
fetchQualifierData();
|
||||
showSuccessNotification = true;
|
||||
successMessage = "Map successfully updated!";
|
||||
setTimeout(() => showSuccessNotification = false, 2500);
|
||||
modalMessage = `Successfully updated the map!`;
|
||||
modalIcon = 'check';
|
||||
modalButtons = [];
|
||||
modalAutoClose = 1;
|
||||
showGenericModal = true;
|
||||
}
|
||||
|
||||
async function handleMapAdded(event: CustomEvent) {
|
||||
await client.addQualifierMaps(tournamentGuid, qualifierGuid, [event.detail.map]);
|
||||
fetchQualifierData();
|
||||
showSuccessNotification = true;
|
||||
successMessage = "Map successfully added!";
|
||||
setTimeout(() => showSuccessNotification = false, 2500);
|
||||
modalMessage = `Successfully added the map!`;
|
||||
modalIcon = 'check';
|
||||
modalButtons = [];
|
||||
modalAutoClose = 1;
|
||||
showGenericModal = true;
|
||||
}
|
||||
|
||||
async function deleteMap() {
|
||||
|
|
@ -186,8 +256,11 @@
|
|||
}
|
||||
|
||||
maps = maps.filter(map => map.taData.guid !== mapToDelete);
|
||||
showSuccessNotification = true;
|
||||
successMessage = "Map successfully deleted!";
|
||||
modalMessage = `Successfully deleted the map!`;
|
||||
modalIcon = 'check';
|
||||
modalButtons = [];
|
||||
modalAutoClose = 1;
|
||||
showGenericModal = true;
|
||||
} catch (err) {
|
||||
console.error('Error deleting map:', err);
|
||||
error = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
|
|
@ -280,6 +353,17 @@
|
|||
return activeModifiers;
|
||||
}
|
||||
|
||||
async function openLeaderboardPopup(map: CustomMap) {
|
||||
currentlyViewingLeaderboardMap = map;
|
||||
const mapScores = await client.getLeaderboard(tournamentGuid, qualifierGuid, map.taData.guid);
|
||||
currentlyViewingLeaderboardMapScores = (mapScores as any).details.leaderboardEntries.scores;
|
||||
showLeaderboardPopup = true;
|
||||
}
|
||||
|
||||
function closeLeaderboardPopup() {
|
||||
showLeaderboardPopup = false;
|
||||
}
|
||||
|
||||
function setFlag(flagType: QualifierEvent_EventSettings, flagValue: boolean) {
|
||||
if (!qualifier) return;
|
||||
|
||||
|
|
@ -299,6 +383,102 @@
|
|||
const tempMap = maps.find(map => map.taData.guid == currentEditMap?.guid);
|
||||
return tempMap ? tempMap : undefined;
|
||||
}
|
||||
|
||||
async function handleDeleteQualifierClicked() {
|
||||
modalMessage = `Are you sure that you want to remove this qualifier? You will not be able to access the scores again and players will not be able to submit new scores!`;
|
||||
modalIcon = 'warning';
|
||||
modalIconColor = 'warning';
|
||||
modalButtons = [
|
||||
{
|
||||
text: 'Close (NO)',
|
||||
type: 'primary',
|
||||
icon: 'cancel',
|
||||
color: 'primary',
|
||||
action: () => showGenericModal = false,
|
||||
},
|
||||
{
|
||||
text: 'Delete (YES)',
|
||||
type: 'secondary',
|
||||
icon: 'warning',
|
||||
color: 'warning',
|
||||
action: async() => await handleDeleteQualifierConfirm(),
|
||||
},
|
||||
];
|
||||
modalAutoClose = 0;
|
||||
showGenericModal = true;
|
||||
}
|
||||
|
||||
async function handleDeleteQualifierConfirm() {
|
||||
const response = await client.deleteQualifierEvent(tournamentGuid, qualifierGuid);
|
||||
|
||||
showGenericModal = false;
|
||||
goto(`/tournaments/${tournamentGuid}/qualifiers`);
|
||||
}
|
||||
|
||||
async function formatLeaderboardData() {
|
||||
const mapsAndScores = [];
|
||||
|
||||
// Use Promise.all to wait for all async operations
|
||||
const mapPromises = maps.map(async (map) => {
|
||||
const mapScoreResponse = await client.getLeaderboard(tournamentGuid, qualifierGuid, map.taData.guid);
|
||||
if(mapScoreResponse.type == Response_ResponseType.Fail) return;
|
||||
const mapScores = (mapScoreResponse as any).details.leaderboardEntries.scores;
|
||||
return {
|
||||
map: map,
|
||||
scores: (mapScores as any).map((score: Score) => ({
|
||||
playerUsername: score.username,
|
||||
playerPlatformId: score.platformId,
|
||||
score: score.modifiedScore,
|
||||
accuracy: score.accuracy,
|
||||
misses: score.notesMissed,
|
||||
badCuts: score.badCuts,
|
||||
isFC: score.fullCombo,
|
||||
multipliedScore: score.multipliedScore,
|
||||
maxCombo: score.maxCombo,
|
||||
goodCuts: score.goodCuts,
|
||||
maxScore: score.maxPossibleScore
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
const results = await Promise.all(mapPromises);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Export functions - only JSON and Excel as requested
|
||||
async function exportJSON() {
|
||||
const data = await formatLeaderboardData();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
saveAs(blob, `${qualifier!.name}_qualifier_scores.json`);
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
const data = await formatLeaderboardData();
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
// Create a separate sheet for each map
|
||||
data.forEach((mapData) => {
|
||||
const worksheet = XLSX.utils.json_to_sheet(mapData!.scores);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, mapData!.map.beatsaverData.metadata.songName.substring(0, 30) || "No Map Name");
|
||||
});
|
||||
|
||||
XLSX.writeFile(workbook, `${qualifier!.name}_qualifier_scores.xlsx`);
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
switch(selectedExportType) {
|
||||
case 'json':
|
||||
await exportJSON();
|
||||
break;
|
||||
case 'excel':
|
||||
await exportExcel();
|
||||
break;
|
||||
default:
|
||||
console.warn('Unsupported export type:', selectedExportType);
|
||||
break;
|
||||
}
|
||||
showExportDropdown = false;
|
||||
}
|
||||
|
||||
onMount(async() => {
|
||||
if ($authTokenStore) {
|
||||
|
|
@ -357,9 +537,15 @@
|
|||
<div class="settings-section">
|
||||
<div class="section-header">
|
||||
<h3>Qualifier Settings</h3>
|
||||
<button class="info-button" on:click={() => openInfoPopup("Configure your qualifier settings including name, image, and Discord integration options.")}>
|
||||
<span class="material-icons">info</span>
|
||||
</button>
|
||||
<div class="button-container">
|
||||
<button class="action-button delete" on:click={handleDeleteQualifierClicked}>
|
||||
<span class="material-icons">delete</span>
|
||||
Delete Qualifier
|
||||
</button>
|
||||
<button class="info-button" on:click={() => openInfoPopup("Configure your qualifier settings including name, image, and Discord integration options.")}>
|
||||
<span class="material-icons">info</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
|
|
@ -486,11 +672,35 @@
|
|||
<div class="maps-section">
|
||||
<div class="section-header">
|
||||
<h3>Maps</h3>
|
||||
<div class="section-actions">
|
||||
<button class="action-button add" on:click={() => openEditMapModal()}>
|
||||
<span class="material-icons">add</span>
|
||||
Add Map
|
||||
</button>
|
||||
<div class="button-container">
|
||||
<div class="export-dropdown">
|
||||
<button
|
||||
class="action-button add"
|
||||
class:disabled={maps.length === 0}
|
||||
on:click={() => showExportDropdown = !showExportDropdown}
|
||||
disabled={maps.length === 0}
|
||||
>
|
||||
<span class="material-symbols-outlined">download</span>
|
||||
<span>Export All Scores</span>
|
||||
</button>
|
||||
{#if showExportDropdown}
|
||||
<div class="dropdown-content">
|
||||
<select bind:value={selectedExportType} class="export-select">
|
||||
<option value="json">JSON</option>
|
||||
<option value="excel">Excel</option>
|
||||
</select>
|
||||
<button class="export-action-btn" on:click={handleExport}>
|
||||
Export All Scores
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="section-actions">
|
||||
<button class="action-button add" on:click={() => openEditMapModal()}>
|
||||
<span class="material-icons">add</span>
|
||||
Add Map
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -533,6 +743,13 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="map-actions">
|
||||
<button
|
||||
class="map-action view"
|
||||
title="View map scores"
|
||||
on:click={() => openLeaderboardPopup(map)}
|
||||
>
|
||||
<span class="material-icons">visibility</span>
|
||||
</button>
|
||||
<button
|
||||
class="map-action edit"
|
||||
title="Edit map"
|
||||
|
|
@ -585,7 +802,6 @@
|
|||
{#if showEditMapModal}
|
||||
<EditMapModal
|
||||
tournamentGuid={tournamentGuid}
|
||||
poolGuid={qualifierGuid}
|
||||
map={getCurrentlyEditingMap()}
|
||||
on:close={closeEditMapModal}
|
||||
on:mapUpdated={handleMapUpdated}
|
||||
|
|
@ -625,13 +841,23 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showLeaderboardPopup}
|
||||
<QualifierLeaderboard
|
||||
mapData={currentlyViewingLeaderboardMap}
|
||||
scores={currentlyViewingLeaderboardMapScores}
|
||||
sortType={qualifier?.sort}
|
||||
on:close={closeLeaderboardPopup}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Success Notification -->
|
||||
<Popup
|
||||
bind:open={showSuccessNotification}
|
||||
message={successMessage}
|
||||
icon="check"
|
||||
iconColor="success"
|
||||
buttons={[]}
|
||||
bind:open={showGenericModal}
|
||||
message={modalMessage}
|
||||
icon={modalIcon}
|
||||
iconColor={modalIconColor}
|
||||
buttons={modalButtons}
|
||||
autoClose={modalAutoClose}
|
||||
on:close={closeNotification}
|
||||
/>
|
||||
|
||||
|
|
@ -728,7 +954,7 @@
|
|||
}
|
||||
|
||||
.action-button.delete {
|
||||
background-color: #ff5555;
|
||||
background-color: #c64141;
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
|
@ -747,6 +973,12 @@
|
|||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -945,6 +1177,15 @@
|
|||
background-color: var(--bg-secondary);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.map-action.view {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.map-action.view:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--accent-glow);
|
||||
}
|
||||
|
||||
.map-action.delete {
|
||||
color: #ff5555;
|
||||
|
|
@ -1245,6 +1486,137 @@
|
|||
.setting-input:focus {
|
||||
outline: 2px solid var(--accent-color);
|
||||
}
|
||||
|
||||
/* Export styles */
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background-color: var(--button-bg);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: var(--button-hover-bg);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
background-color: var(--comfortable-blue);
|
||||
color: white;
|
||||
position: relative;
|
||||
box-shadow: 0 0 10px rgb(71, 73, 202);
|
||||
}
|
||||
|
||||
.export-btn:hover:not(.disabled) {
|
||||
background-color: rgb(71, 73, 202);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.export-btn.disabled {
|
||||
background-color: #4a4a4a;
|
||||
color: #888888;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.export-dropdown {
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 2px solid #4fc3f7;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
z-index: 1001;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 20px rgba(79, 195, 247, 0.3);
|
||||
animation: dropdownSlide 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes dropdownSlide {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: 16px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8px solid transparent;
|
||||
border-right: 8px solid transparent;
|
||||
border-bottom: 8px solid #4fc3f7;
|
||||
}
|
||||
|
||||
.export-select {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.export-select:hover {
|
||||
border-color: #4fc3f7;
|
||||
background-color: rgba(79, 195, 247, 0.05);
|
||||
}
|
||||
|
||||
.export-select:focus {
|
||||
border-color: #4fc3f7;
|
||||
box-shadow: 0 0 0 2px rgba(79, 195, 247, 0.2);
|
||||
background-color: rgba(79, 195, 247, 0.05);
|
||||
}
|
||||
|
||||
.export-action-btn {
|
||||
background-color: var(--pick-green);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.export-action-btn:hover {
|
||||
background-color: var(--pick-green-hover);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.export-action-btn:active {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue