ShyyTAUI/src/routes/tournaments/+page.svelte
2025-05-24 02:08:25 +02:00

1123 lines
No EOL
33 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import Notification from '$lib/components/notifications/Popup.svelte';
import { discordAuthUrl } from '$lib/config.json';
import { discordDataStore, discordTokenStore, authTokenStore, TAServerPort, TAServerUrl, client } from '$lib/stores';
import { TAClient, Response_ResponseType, Tournament } from 'moons-ta-client';
import { bufferToImageUrl } from '$lib/services/taImages';
import { v4 as uuidv4 } from "uuid";
import { convertImageToUint8Array } from '$lib/services/taImages';
interface CustomTournament {
id: string;
name: string;
image: string;
guid: string;
// authorisedUsers: any;
}
let tournaments: CustomTournament[] = [];
let loading = true;
let error = false;
let authError: string | null = null;
let showLoginNotification = false;
let isLoggedIn = false;
let userAvatar = '';
let username = '';
// New state for tournament creation
let showCreateTournamentModal = false;
let newTournamentName = '';
let newTournamentImage: File | null = null;
let imagePreviewUrl = '';
let nameError = '';
let creating = false;
// Remove 'Bearer ' from the token
if($authTokenStore) {
const cleanToken = $authTokenStore.replace('Bearer ', '');
client.setAuthToken(cleanToken);
}
onMount(async () => {
try {
// Check if user is logged in
isLoggedIn = !!$authTokenStore && !!$discordDataStore;
if (!isLoggedIn) {
showLoginNotification = true;
loading = false;
return;
}
// Set user profile data
username = $discordDataStore.global_name;
let avatarResponse = await fetch(`https://cdn.discordapp.com/avatars/${$discordDataStore.id}/${$discordDataStore.avatar}.png`);
if(!avatarResponse.ok) {
userAvatar = "/talogo.png";
} else {
userAvatar = `https://cdn.discordapp.com/avatars/${$discordDataStore.id}/${$discordDataStore.avatar}.png`;
}
try {
if(!client.isConnected) {
const connectResult = await client.connect($TAServerUrl, $TAServerPort);
if (connectResult.details.oneofKind === "connect" && connectResult.type === Response_ResponseType.Fail) {
authError = connectResult.details.connect.message;
loading = false;
return;
}
}
// Get tournaments from the client
const tournamentsList = client.stateManager.getTournaments();
tournaments = await Promise.all(tournamentsList.map(async (t) => {
// Process tournament image
let imageUrl = '/talogo.png'; // Default fallback
if (t.settings?.tournamentImage) {
// If the image is already a string URL
if (typeof t.settings.tournamentImage === 'string') {
imageUrl = t.settings.tournamentImage;
}
// If the image is a Uint8Array
else if (t.settings.tournamentImage instanceof Uint8Array) {
// Create and properly dispose of object URLs to prevent memory leaks
try {
imageUrl = bufferToImageUrl(t.settings.tournamentImage);
if (imageUrl.length < 150) {
imageUrl = "/talogo.png"
}
} catch (error) {
console.error('Failed to convert tournament image buffer to URL:', error);
// Fall back to default image
imageUrl = "/talogo.png"
}
}
}
// Fetch authorized users for this tournament
// This initiates the API call immediately but doesn't block the map function
// const authorisedUsersPromise = await fetchTournamentAuthorisedUsers(t.guid);
return {
id: t.guid.substring(0, 8), // Short ID for display
name: t.settings?.tournamentName || 'Unnamed Tournament',
image: imageUrl,
guid: t.guid,
// authorisedUsers: authorisedUsersPromise // Wait for the promise to resolve
};
}));
} catch (connErr) {
console.error('TAClient connection error:', connErr);
authError = connErr instanceof Error ? connErr.message : 'Failed to connect to TA server';
}
loading = false;
} catch (e) {
console.error('Failed to fetch tournaments', e);
error = true;
loading = false;
}
});
onDestroy(() => {
// if (client.isConnected == true) {
// // Properly disconnect and clean up any listeners
// client.disconnect();
// }
});
async function fetchTournamentAuthorisedUsers(tournamentGuid: string) {
// Get the authorised users for the tournament
let authorisedUsers = [];
try {
const response = await client.getAuthorizedUsers(tournamentGuid);
console.log(response)
authorisedUsers = (response as any).details.getAuthorizedUsers.authorizedUsers;
return authorisedUsers;
} catch (error) {
console.error(`Failed to get authorized users for tournament ${tournamentGuid}:`, error);
return authorisedUsers;
}
}
function closeNotification() {
showLoginNotification = false;
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text)
.then(() => {
console.log('Copied to clipboard!');
})
.catch(err => {
console.error('Failed to copy: ', err);
});
}
// Tournament creation functions
function openCreateTournamentModal() {
showCreateTournamentModal = true;
}
function closeCreateTournamentModal() {
showCreateTournamentModal = false;
newTournamentName = '';
newTournamentImage = null;
imagePreviewUrl = '';
nameError = '';
}
function handleImageChange(event: Event) {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
newTournamentImage = target.files[0];
imagePreviewUrl = URL.createObjectURL(target.files[0]);
}
}
function validateTournamentName() {
if (!newTournamentName.trim()) {
nameError = "Tournament name cannot be empty";
return false;
}
// Check for duplicate names
if (tournaments.some(t => t.name.toLowerCase() === newTournamentName.toLowerCase().trim())) {
nameError = "A tournament with this name already exists";
return false;
}
nameError = "";
return true;
}
function handleTournamentNameInput() {
if (newTournamentName.trim()) {
validateTournamentName();
} else {
nameError = "";
}
}
async function createTournament() {
if (!validateTournamentName()) {
return;
}
try {
creating = true;
// Connect to the TA server
const connectResult = await client.connect('server.tournamentassistant.net', '8676');
if (connectResult.details.oneofKind === "connect" && connectResult.type === Response_ResponseType.Fail) {
authError = connectResult.details.connect.message;
creating = false;
return;
}
// Create the tournament object
let tournament: Tournament = {
guid: uuidv4(),
users: [],
matches: [],
qualifiers: [],
settings: {
tournamentName: newTournamentName.trim(),
tournamentImage: new Uint8Array([1]), // Default placeholder
enableTeams: false,
enablePools: false,
showTournamentButton: true,
showQualifierButton: true,
teams: [],
scoreUpdateFrequency: 30,
bannedMods: [],
pools: [],
allowUnauthorizedView: true
},
};
// Convert image to Uint8Array if an image was provided
if (newTournamentImage) {
try {
tournament.settings!.tournamentImage = await convertImageToUint8Array(newTournamentImage);
console.log("Image converted successfully", tournament.settings!.tournamentImage.length, "bytes");
} catch (error) {
console.error("Failed to convert image:", error);
// Continue with default image
}
}
// Create the tournament
console.log("Creating tournament:", tournament);
const response = await client.createTournament(tournament);
console.log("Tournament created:", response);
// Add the new tournament to the list (for immediate display)
let imageUrl = '/images/tournaments/default.jpg'; // Default fallback
if (imagePreviewUrl) {
imageUrl = imagePreviewUrl;
}
tournaments = [
...tournaments,
{
id: tournament.guid.substring(0, 8),
name: tournament.settings!.tournamentName,
image: imageUrl,
guid: tournament.guid,
// authorisedUsers: []
}
];
// Close the modal after successful creation
closeCreateTournamentModal();
} catch (error) {
console.error("Failed to create tournament:", error);
authError = error instanceof Error ? error.message : "Failed to create tournament";
} finally {
creating = false;
}
}
</script>
<main>
<section class="hero-section">
<div class="content">
<h1>Tournaments</h1>
</div>
</section>
{#if authError}
<div class="error-message">
<i class="material-icons">error</i>
<p>{authError}</p>
</div>
{/if}
<section class="tournaments-section">
<div class="section-header">
<div class="spacer"></div>
{#if isLoggedIn}
<button class="create-tournament-btn" on:click={openCreateTournamentModal}>
<i class="material-icons">add</i>
<span>Create Tournament</span>
</button>
{/if}
</div>
{#if loading}
<div class="loading-container">
<div class="loading-spinner"></div>
<p>Loading tournaments...</p>
</div>
{:else if error}
<div class="error-container">
<div class="error-icon">
<i class="material-icons">error_outline</i>
</div>
<p>Failed to load tournaments. Please try again later.</p>
<button class="retry-button" on:click={() => window.location.reload()}>
<i class="material-icons">refresh</i>
Retry
</button>
</div>
{:else if tournaments.length === 0}
<div class="empty-container">
<div class="empty-icon">
<i class="material-icons">event_busy</i>
</div>
<p>No tournaments found.</p>
{#if isLoggedIn}
<button class="create-first-tournament-btn" on:click={openCreateTournamentModal}>
<i class="material-icons">add_circle</i>
Create Your First Tournament
</button>
{/if}
</div>
{:else}
<div class="tournaments-grid">
{#each tournaments as tournament}
<a href={`/tournaments/${tournament.guid}`} class="tournament-card-link">
<div class="tournament-card">
<div class="tournament-background" style="background-image: url({tournament.image})"></div>
<div class="tournament-content">
<div class="tournament-image">
<img src={tournament.image || "/talogo.png"} alt={tournament.name} />
</div>
<div class="tournament-info">
<h3>{tournament.name}</h3>
<div class="tournament-guid">
<span>{tournament.guid}</span>
<button class="copy-button" on:click|stopPropagation={(e) => {
e.preventDefault();
copyToClipboard(tournament.guid);
}}>
<i class="material-icons">content_copy</i>
</button>
</div>
</div>
</div>
</div>
</a>
{/each}
</div>
{/if}
</section>
<!-- Create Tournament Modal -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
{#if showCreateTournamentModal}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="modal-backdrop" on:click={closeCreateTournamentModal}></div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="modal-container">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="modal-content" on:click|stopPropagation>
<div class="modal-header">
<h2>Create New Tournament</h2>
<button class="close-btn" on:click={closeCreateTournamentModal}>
<i class="material-icons">close</i>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="tournament-name">Tournament Name</label>
<input
type="text"
id="tournament-name"
bind:value={newTournamentName}
on:input={handleTournamentNameInput}
placeholder="Enter tournament name"
class={nameError ? "error" : ""}
/>
{#if nameError}
<div class="error-text">
<i class="material-icons">error_outline</i>
<span>{nameError}</span>
</div>
{/if}
</div>
<div class="form-group">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label>Tournament Image</label>
<div class="image-upload-area" class:has-image={!!imagePreviewUrl}>
{#if imagePreviewUrl}
<div class="image-preview">
<img src={imagePreviewUrl} alt="Tournament preview" />
<button class="remove-image-btn" on:click={() => {
imagePreviewUrl = '';
newTournamentImage = null;
}}>
<i class="material-icons">delete</i>
</button>
</div>
{:else}
<label for="tournament-image" class="upload-label">
<i class="material-icons">cloud_upload</i>
<span>Upload Image</span>
<p class="upload-hint">Drop image here or click to browse</p>
</label>
{/if}
<input
type="file"
id="tournament-image"
accept="image/*"
on:change={handleImageChange}
class="file-input"
/>
</div>
</div>
</div>
<div class="modal-footer">
<button class="cancel-btn" on:click={closeCreateTournamentModal}>Cancel</button>
<button
class="create-btn"
on:click={createTournament}
disabled={!newTournamentName.trim() || !!nameError || creating}
>
{#if creating}
<div class="btn-spinner"></div>
Creating...
{:else}
Create Tournament
{/if}
</button>
</div>
</div>
</div>
{/if}
<Notification
bind:open={showLoginNotification}
message="You need to be logged in with Discord to view tournaments"
icon="login"
iconColor="primary"
buttons={[
{
text: "Login with Discord",
type: "primary",
href: discordAuthUrl,
icon: "discord",
color: "primary"
},
{
text: "Back to Home",
type: "outlined",
href: "/",
icon: "home",
color: "secondary"
}
]}
on:close={closeNotification}
/>
</main>
<style>
/* Main content */
main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1.5rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
/* Hero section */
.hero-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 1rem 0;
width: 100%;
}
.content {
max-width: 800px;
}
.hero-section h1 {
font-size: 3.5rem;
background: linear-gradient(90deg, var(--accent-color), var(--accent-hover));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 0 15px var(--accent-glow);
}
.hero-section p {
font-size: 1.25rem;
color: var(--text-secondary);
margin-bottom: 3rem;
line-height: 1.6;
}
/* Section header with Create Tournament button */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-bottom: 1.5rem;
}
.spacer {
flex: 1;
}
.create-tournament-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: linear-gradient(135deg, var(--accent-color), var(--accent-hover));
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.create-tournament-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.2), 0 0 10px var(--accent-glow);
}
.create-tournament-btn:active {
transform: translateY(0);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.create-tournament-btn i {
font-size: 1.25rem;
}
.create-first-tournament-btn {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1.5rem;
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, var(--accent-color), var(--accent-hover));
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.create-first-tournament-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.2), 0 0 10px var(--accent-glow);
}
/* Error message */
.error-message {
display: flex;
align-items: center;
background-color: rgba(255, 0, 0, 0.1);
border-left: 4px solid #ff5555;
padding: 1rem;
border-radius: 4px;
margin-bottom: 2rem;
width: 100%;
}
.error-message i {
color: #ff5555;
margin-right: 0.75rem;
}
/* Tournaments section */
.tournaments-section {
width: 100%;
margin-top: 2rem;
}
.tournaments-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem;
width: 100%;
}
.tournament-card-link {
text-decoration: none;
color: inherit;
display: block;
}
.tournament-card {
position: relative;
background-color: var(--bg-secondary);
border-radius: 12px;
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
border: 1px solid var(--border-color);
height: 180px;
display: flex;
flex-direction: column;
}
.tournament-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
}
.tournament-background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-size: cover;
background-position: center;
filter: blur(10px) brightness(0.5);
transform: scale(1.1);
z-index: 1;
}
.tournament-content {
position: relative;
z-index: 2;
display: flex;
height: 100%;
}
.tournament-image {
width: 140px;
height: 100%;
flex-shrink: 0;
overflow: hidden;
border-radius: 8px 0 0 8px;
}
.tournament-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.tournament-info {
padding: 1.5rem;
display: flex;
flex-direction: column;
flex: 1;
justify-content: space-between;
}
.tournament-info h3 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
.tournament-guid {
display: flex;
align-items: center;
font-size: 0.75rem;
color: #999;
margin-top: auto;
}
.copy-button {
background: none;
border: none;
cursor: pointer;
color: #999;
padding: 0;
margin-left: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.5;
transition: opacity 0.2s;
}
.copy-button:hover {
opacity: 1;
}
.copy-button i {
font-size: 0.875rem;
}
/* Loading, error, and empty states */
.loading-container,
.error-container,
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
width: 100%;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(var(--accent-color-rgb), 0.2);
border-radius: 50%;
border-top-color: var(--accent-color);
animation: spin 1s linear infinite;
margin-bottom: 1.5rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-icon,
.empty-icon {
font-size: 3rem;
color: var(--accent-color);
margin-bottom: 1.5rem;
}
.error-icon i,
.empty-icon i {
font-size: 3.5rem;
}
.retry-button {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1.5rem;
padding: 0.75rem 1.5rem;
background-color: var(--accent-color);
color: white;
border: none;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.retry-button:hover {
background-color: var(--accent-hover);
box-shadow: 0 0 15px var(--accent-glow);
}
/* Modal styles */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
z-index: 1000;
animation: fadeIn 0.2s ease;
}
.modal-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
padding: 1rem;
}
.modal-content {
background-color: var(--bg-primary);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
animation: modalSlideIn 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
font-size: 1.5rem;
margin: 0;
color: var(--text-primary);
}
.close-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.5rem;
margin: -0.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.close-btn:hover {
background-color: rgba(0, 0, 0, 0.1);
color: var(--text-primary);
}
.modal-body {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--text-secondary);
}
.form-group input[type="text"] {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: var(--bg-secondary);
color: var(--text-primary);
font-size: 1rem;
transition: all 0.2s;
}
.form-group input[type="text"]:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(var(--accent-color-rgb), 0.2);
outline: none;
}
.form-group input[type="text"].error {
border-color: #ff5555;
}
.error-text {
display: flex;
align-items: center;
color: #ff5555;
margin-top: 0.5rem;
font-size: 0.875rem;
}
.error-text i {
font-size: 1rem;
margin-right: 0.25rem;
}
.image-upload-area {
border: 2px dashed var(--border-color);
border-radius: 8px;
padding: 2rem;
text-align: center;
transition: all 0.2s;
position: relative;
background-color: var(--bg-secondary);
}
.image-upload-area:hover {
border-color: var(--accent-color);
}
.image-upload-area.has-image {
padding: 0;
border-style: solid;
}
.upload-label {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
}
.upload-label i {
font-size: 2.5rem;
color: var(--accent-color);
margin-bottom: 0.75rem;
}
.upload-label span {
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.upload-hint {
color: var(--text-secondary);
font-size: 0.875rem;
margin: 0;
}
.file-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.image-preview {
position: relative;
width: 100%;
height: 200px;
border-radius: 6px;
overflow: hidden;
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.remove-image-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background-color: rgba(0, 0, 0, 0.7);
color: white;
border: none;
border-radius: 50%;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.remove-image-btn:hover {
background-color: rgba(255, 0, 0, 0.7);
transform: scale(1.1);
}
.modal-footer {
display: flex;
justify-content: flex-end;
padding: 1.25rem 1.5rem;
border-top: 1px solid var(--border-color);
gap: 1rem;
}
.cancel-btn {
padding: 0.75rem 1.25rem;
background-color: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.cancel-btn:hover {
background-color: var(--bg-secondary);
color: var(--text-primary);
}
.create-btn {
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, var(--accent-color), var(--accent-hover));
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.create-btn:hover:not(:disabled) {
box-shadow: 0 0 15px var(--accent-glow);
transform: translateY(-2px);
}
.create-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-spinner {
width: 1rem;
height: 1rem;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-icon,
.empty-icon {
font-size: 3rem;
color: var(--accent-color);
margin-bottom: 1.5rem;
}
.error-icon i,
.empty-icon i {
font-size: 3.5rem;
}
.retry-button {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1.5rem;
padding: 0.75rem 1.5rem;
background-color: var(--accent-color);
color: white;
border: none;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.retry-button:hover {
background-color: var(--accent-hover);
box-shadow: 0 0 15px var(--accent-glow);
}
/* Responsive design */
@media (max-width: 768px) {
.hero-section h1 {
font-size: 2.5rem;
}
.hero-section p {
font-size: 1rem;
}
.tournaments-grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.tournament-image {
width: 120px;
}
}
@media (max-width: 480px) {
.hero-section h1 {
font-size: 2rem;
}
.tournaments-grid {
grid-template-columns: 1fr;
}
.tournament-card {
height: 160px;
}
.tournament-image {
width: 100px;
}
}
</style>