1115 lines
No EOL
33 KiB
Svelte
1115 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, TAServerPlayerPort } 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;
|
|
}
|
|
});
|
|
|
|
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;
|
|
|
|
if(!client.isConnected) 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
|
|
},
|
|
server: {
|
|
name: `${$TAServerUrl}:${$TAServerPort}`,
|
|
address: $TAServerUrl,
|
|
port: $TAServerPlayerPort,
|
|
websocketPort: $TAServerPort
|
|
}
|
|
};
|
|
|
|
// 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> |