ShyyTAUI/src/routes/tournaments/[tournamentguid]/mappools/[poolGuid]/+page.svelte

968 lines
No EOL
28 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from "svelte";
//@ts-ignore
import { TAClient, Response_ResponseType, Map, Tournament, GameplayModifiers_GameOptions } from 'moons-ta-client';
import { TABotTokenStore, TAServerPort, TAServerUrl, authTokenStore, client } from "$lib/stores";
import { bufferToImageUrl } from "$lib/services/taImages.js";
import SideMenu from "$lib/components/menus/SideMenuTournaments.svelte";
import Popup from "$lib/components/notifications/Popup.svelte";
import InfoPopup from "$lib/components/notifications/InfoPopup.svelte";
import EditMapModal from "$lib/components/popups/EditMap.svelte";
import { goto } from "$app/navigation";
export let data;
const tournamentGuid: string = data.tournamentGuid;
const poolGuid: string = data.poolGuid;
let tournament: Tournament | undefined;
let isLoading = false;
let error: string | null = null;
let poolName: string = "";
let maps: {
taData: Map,
beatsaverData: BeatSaverMap
}[] = [];
type BeatSaverMap = {
id: string;
metadata: {
songName: string;
songAuthorName: string;
levelAuthorName: string;
songSubName: string;
duration: number;
bpm: number;
};
versions: Array<{
hash: string;
state: string;
createdAt: string;
downloadURL: string;
diffs: Array<{
characteristic: string;
difficulty: string;
maxScore: number;
nps: number;
}>;
coverURL: string;
previewURL: string;
}>;
uploaded: string;
};
enum MapDifficulty {
"Easy" = 0,
"Normal" = 1,
"Hard" = 2,
"Expert" = 3,
"Expert+" = 4
}
interface Modifier {
id: GameplayModifiers_GameOptions;
name: string;
description: string;
enabled: boolean;
disabled?: boolean;
group?: string;
incompatibilityGroup?: string;
allowedInMode: string[];
}
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"
};
// Edit Map Modal state
let showEditMapModal: boolean = false;
let currentEditMap: Map | null = null;
// Delete confirmation popup
let showDeleteConfirmation = false;
let mapToDelete: string | null = null;
// Info popup state
let showInfoPopup: boolean = false;
let infoPopupContent: string = "";
// Success notification
let showSuccessNotification = false;
let successMessage = "";
// Remove 'Bearer ' from the token
if ($authTokenStore) {
const cleanToken = $authTokenStore.replace('Bearer ', '');
client.setAuthToken(cleanToken);
}
function openInfoPopup(content: string) {
infoPopupContent = content;
showInfoPopup = true;
}
function closeInfoPopup() {
showInfoPopup = false;
}
function openEditMapModal(map: Map | null = null) {
currentEditMap = map;
showEditMapModal = true;
}
function closeEditMapModal() {
showEditMapModal = false;
currentEditMap = null;
}
function openDeleteConfirmation(mapId: string) {
mapToDelete = mapId;
showDeleteConfirmation = true;
}
function closeDeleteConfirmation() {
showDeleteConfirmation = false;
mapToDelete = null;
}
function closeNotification() {
showSuccessNotification = false;
}
async function handleMapUpdated(event: CustomEvent) {
const res = await client.updateTournamentPoolMap(tournamentGuid, poolGuid, event.detail.map);
console.log(res)
fetchMapPoolData();
showSuccessNotification = true;
successMessage = "Map successfully updated!";
}
function handleMapAdded(event: CustomEvent) {
fetchMapPoolData();
showSuccessNotification = true;
successMessage = "Map successfully added!";
}
async function deleteMap() {
if (!mapToDelete) return;
isLoading = true;
try {
// Implement actual map deletion - this will depend on your API
const removeMapResponse = await client.removeTournamentPoolMap(tournamentGuid, poolGuid, mapToDelete);
if (removeMapResponse.type !== Response_ResponseType.Success) {
throw new Error("Failed to remove map! Please refresh this page!");
}
maps = maps.filter(map => map.taData.guid !== mapToDelete);
showSuccessNotification = true;
successMessage = "Map successfully deleted!";
} catch (err) {
console.error('Error deleting map:', err);
error = err instanceof Error ? err.message : 'An unknown error occurred';
} finally {
isLoading = false;
closeDeleteConfirmation();
}
}
function goBackToMapPools() {
goto(`/tournaments/${tournamentGuid}/mappools`);
}
async function fetchMapPoolData() {
if (!$authTokenStore) {
window.location.href = '/discordAuth';
return;
}
isLoading = true;
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');
}
}
tournament = client.stateManager.getTournament(tournamentGuid)!;
client.stateManager.on('tournamentUpdated', (data) => console.log("updateGlobal", data));
// Find the map pool with the specified GUID
const pool = await tournament?.settings?.pools.find(p => p.guid === poolGuid);
if (!pool) {
throw new Error('Map pool not found');
}
poolName = pool.name;
maps = [];
await pool.maps.map(async(map) => {
console.log("mapData that was newly fetched", map);
let beatsaverData = await getMapBeatsaverData(map.gameplayParameters!.beatmap!.levelId);
maps = [...maps, {
taData: map,
beatsaverData: beatsaverData
}];
});
client.on('failedToUpdateTournament', (data) => {console.log("tourneyUpdate", data)})
client.stateManager.on('tournamentUpdated', (data) => {console.log("tourneyUpdateSM", data)});
maps = maps;
} catch (err) {
console.error('Error fetching map pool data:', err);
error = err instanceof Error ? err.message : 'An unknown error occurred';
} finally {
isLoading = false;
}
}
async function getMapBeatsaverData(levelId: string): Promise<BeatSaverMap> {
const newLevelId = levelId.replace("custom_level_", "");
const data = await fetch(`https://api.beatsaver.com/maps/hash/${newLevelId}`);
if(!data.ok) {
error = "Unable to fetch a map-s BeatSaver information. Please go to inspect, console, and take a screenshot and send it to serverbp or matrikmoon on Discord."
}
const response: BeatSaverMap = await data.json();
return response;
}
function getActiveModifiersCompact(gameplayModifiers: number): string[] {
const activeModifiers: string[] = [];
// Get all numeric enum values and sort them
const values = Object.values(GameplayModifiers_GameOptions)
.filter((value): value is number =>
typeof value === 'number' &&
value !== GameplayModifiers_GameOptions.None
)
.sort((a, b) => a - b);
console.log(values)
for (const value of values) {
if (gameplayModifiers & value) {
const name = modifierNameMap[value];
if (name && name.trim() !== "") {
activeModifiers.push(name);
}
}
}
return activeModifiers;
}
function getModifierArray(map: any): string[] {
if (map.taData.gameplayParameters?.gameplayModifiers.options == 0) {
return [];
}
return getActiveModifiersCompact(map.taData.gameplayParameters.gameplayModifiers);
}
onMount(async() => {
if ($authTokenStore) {
await fetchMapPoolData();
} else {
window.location.href = "/discordAuth";
}
});
</script>
<div class="layout">
<main class="dashboard">
<SideMenu currentPage="mappools" tournamentName={tournament?.settings?.tournamentName} tournamnentGuid={tournamentGuid} />
<div class="content-area">
<div class="content-header">
<div class="header-left">
<button class="back-button" on:click={goBackToMapPools}>
<span class="material-icons">arrow_back</span>
Back to Map Pools
</button>
<h2>{poolName} Maps</h2>
</div>
<div class="actions">
{#if !error && !isLoading}
<button class="action-button add" on:click={() => openEditMapModal()}>
<span class="material-icons">add</span>
Add Map
</button>
<button class="action-button refresh" on:click={fetchMapPoolData}>
<span class="material-icons">refresh</span>
Refresh
</button>
{/if}
</div>
</div>
<div class="maps-header">
<h3>Maps in this Pool</h3>
<button class="info-button" on:click={() => openInfoPopup("Maps are Beat Saber songs that can be played in your tournament. Each map can have different modifiers and settings configured.")}>
<span class="material-icons">info</span>
</button>
</div>
{#if isLoading}
<div class="loading">
<div class="spinner"></div>
<p>Loading maps data...</p>
</div>
{:else if error}
<div class="error-container">
<span class="material-icons">error_outline</span>
<p>{error}</p>
<button class="retry-button" on:click={fetchMapPoolData}>Retry</button>
</div>
{:else if maps.length === 0}
<div class="no-maps">
<span class="material-icons">music_note</span>
<p>No maps have been added to this pool yet</p>
<button class="action-button add" on:click={() => openEditMapModal()}>
<span class="material-icons">add</span>
Add First Map
</button>
</div>
{:else}
<div class="maps-list">
{#each maps as map}
<div class="map-card">
<div class="map-content">
<div class="map-image">
<img
src={map.beatsaverData.versions[0].coverURL}
alt="{map.taData.gameplayParameters?.beatmap?.name} cover"
/>
</div>
<div class="map-info">
<h4>{map.taData.gameplayParameters?.beatmap?.name}</h4>
<p class="map-author">By {map.beatsaverData.metadata.levelAuthorName || "Unknown Artist"}</p>
<div class="map-details">
{#if map.taData.gameplayParameters?.beatmap?.difficulty}
<span class="map-difficulty">
<span class="material-icons">speed</span>
{MapDifficulty[map.taData.gameplayParameters?.beatmap?.difficulty]}
</span>
{/if}
{#if map.beatsaverData.metadata.duration}
<span class="map-length">
<span class="material-icons">access_time</span>
{Math.floor(map.beatsaverData.metadata.duration / 60)} minutes {map.beatsaverData.metadata.duration - Math.floor(map.beatsaverData.metadata.duration / 60) * 60} seconds
</span>
{/if}
</div>
</div>
<div class="map-actions">
<button
class="map-action edit"
title="Edit map"
on:click={() => openEditMapModal(map.taData)}
>
<span class="material-icons">edit</span>
</button>
<button
class="map-action delete"
title="Delete map"
on:click={() => openDeleteConfirmation(map.taData.guid)}
>
<span class="material-icons">delete</span>
</button>
</div>
</div>
<div class="map-modifiers">
<h5>Active Modifiers</h5>
<div class="modifier-tags">
{#each getModifierArray(map) as modifier}
<span class="modifier-tag">{modifier}</span>
{:else}
<span class="no-modifiers">No modifiers active</span>
{/each}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</main>
</div>
<!-- Edit Map Modal Component -->
{#if showEditMapModal}
<EditMapModal
tournamentGuid={tournamentGuid}
poolGuid={poolGuid}
map={maps.find(map => map.taData.guid == currentEditMap?.guid)}
on:close={closeEditMapModal}
on:mapUpdated={handleMapUpdated}
on:mapAdded={handleMapAdded}
mode='playing'
/>
{/if}
<!-- Info Popup -->
{#if showInfoPopup}
<InfoPopup content={infoPopupContent} onClose={closeInfoPopup} />
{/if}
<!-- Delete Confirmation Popup -->
{#if showDeleteConfirmation}
<div class="popup-overlay">
<div class="popup-container delete-confirmation">
<div class="popup-header">
<h3>Delete Map</h3>
<button class="close-button" on:click={closeDeleteConfirmation}>
<span class="material-icons">close</span>
</button>
</div>
<div class="popup-content">
<p>Are you sure you want to delete this map?</p>
<p class="warning">This action cannot be undone!</p>
<div class="popup-actions">
<button class="action-button cancel" on:click={closeDeleteConfirmation}>Cancel</button>
<button class="action-button delete" on:click={deleteMap}>
Delete Map
</button>
</div>
</div>
</div>
</div>
{/if}
<!-- Success Notification -->
<Popup
bind:open={showSuccessNotification}
message={successMessage}
icon="check"
iconColor="success"
buttons={[]}
on:close={closeNotification}
/>
<style>
/* Main layout */
.layout {
display: flex;
height: 100vh;
overflow: hidden;
}
.dashboard {
display: flex;
flex: 1;
overflow: hidden;
}
.content-area {
flex: 1;
padding: 2rem;
overflow-y: auto;
}
/* Content header */
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.back-button {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
color: var(--accent-color);
font-weight: 500;
padding: 0.5rem 0;
cursor: pointer;
transition: color 0.2s;
margin-right: 2rem;
}
.back-button:hover {
color: var(--accent-hover);
}
.content-header h2 {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.actions {
display: flex;
gap: 0.75rem;
}
.action-button {
display: flex;
align-items: center;
gap: 0.5rem;
background-color: var(--bg-secondary);
border: none;
border-radius: 0.375rem;
color: var(--text-primary);
font-weight: 500;
padding: 0.5rem 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
.action-button.add,
.action-button.create {
background-color: var(--accent-color);
color: white;
}
.action-button.add:hover,
.action-button.create:hover {
background-color: var(--accent-hover);
}
.action-button.cancel {
background-color: var(--bg-secondary);
}
.action-button.cancel:hover {
background-color: var(--bg-tertiary);
}
.action-button.delete {
background-color: #ff5555;
color: white;
}
.action-button.delete:hover {
background-color: #ff3333;
}
.action-button.refresh:hover {
background-color: var(--bg-tertiary);
}
.action-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Maps header with info button */
.maps-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.maps-header h3 {
font-size: 1.25rem;
font-weight: 500;
margin: 0;
}
.info-button {
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-color);
padding: 0;
}
.info-button .material-icons {
font-size: 1.125rem;
}
.info-button:hover {
color: var(--accent-hover);
}
/* Maps list */
.maps-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.map-card {
background-color: var(--bg-secondary);
border-radius: 0.5rem;
padding: 1.25rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.map-content {
display: flex;
gap: 1.25rem;
}
.map-image {
flex-shrink: 0;
width: 6rem;
height: 6rem;
}
.map-image img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.375rem;
background-color: var(--bg-tertiary);
}
.map-info {
flex: 1;
display: flex;
flex-direction: column;
}
.map-info h4 {
margin: 0 0 0.25rem 0;
font-size: 1.125rem;
font-weight: 500;
}
.map-author {
color: var(--text-secondary);
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
}
.map-details {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
.map-difficulty,
.map-length {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
.map-difficulty .material-icons,
.map-length .material-icons {
font-size: 1rem;
}
.map-actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.map-action {
background: none;
border: none;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-secondary);
}
.map-action:hover {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.map-action.edit:hover {
color: var(--accent-color);
}
.map-action.delete:hover {
color: #ff5555;
}
.map-modifiers {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--bg-tertiary);
}
.map-modifiers h5 {
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
}
.modifier-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.modifier-tag {
background-color: var(--bg-tertiary);
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.8rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.modifier-tag::before {
content: "•";
color: var(--accent-color);
font-weight: bold;
}
.no-modifiers {
color: var(--text-secondary);
font-size: 0.875rem;
font-style: italic;
}
/* No maps state */
.no-maps {
background-color: var(--bg-secondary);
border-radius: 0.75rem;
padding: 3rem 2rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.no-maps .material-icons {
font-size: 3rem;
color: var(--text-secondary);
margin-bottom: 1rem;
}
.no-maps p {
color: var(--text-secondary);
margin-bottom: 1.5rem;
}
/* Loading state */
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 0;
}
.spinner {
width: 3rem;
height: 3rem;
border: 0.25rem solid rgba(var(--accent-color-rgb), 0.2);
border-radius: 50%;
border-top-color: var(--accent-color);
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Error state */
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 0;
text-align: center;
}
.error-container .material-icons {
font-size: 3rem;
color: #ff5555;
margin-bottom: 1rem;
}
.retry-button {
background-color: var(--accent-color);
border: none;
border-radius: 0.375rem;
color: white;
font-weight: 500;
padding: 0.5rem 1rem;
margin-top: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
.retry-button:hover {
background-color: var(--accent-hover);
}
/* Delete confirmation popup */
.popup-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;
}
.popup-container {
background-color: var(--bg-primary);
border-radius: 0.75rem;
width: 90%;
max-width: 500px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--bg-secondary);
}
.popup-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.close-button {
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
width: 2rem;
height: 2rem;
border-radius: 0.25rem;
}
.close-button:hover {
background-color: var(--bg-secondary);
color: var(--text-primary);
}
.popup-content {
padding: 1.5rem;
}
.popup-content p {
margin-top: 0;
}
.popup-content .warning {
color: #ff5555;
font-weight: 500;
}
.popup-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
/* Responsive design */
@media (max-width: 768px) {
.content-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.header-left {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.actions {
width: 100%;
}
.action-button {
flex: 1;
justify-content: center;
}
.map-content {
flex-direction: column;
}
.map-image {
width: 100%;
height: auto;
aspect-ratio: 1;
max-width: 10rem;
margin: 0 auto 1rem;
}
.map-actions {
margin-left: 0;
margin-top: 1rem;
justify-content: flex-end;
}
}
</style>