Init multiplayer
All checks were successful
Deploy Feature / deploy-feature (push) Successful in 28s
All checks were successful
Deploy Feature / deploy-feature (push) Successful in 28s
This commit is contained in:
352
src/App.vue
352
src/App.vue
@@ -7,7 +7,11 @@
|
||||
</div>
|
||||
<div v-if="step === 'game'" class="selector-pill">
|
||||
<span class="label">Selecting</span>
|
||||
<span class="value">{{ currentSelector?.name || '—' }}</span>
|
||||
<span class="value">{{ currentSelector?.name || '-' }}</span>
|
||||
</div>
|
||||
<div v-if="gameId" class="selector-pill session-pill">
|
||||
<span class="label">Game ID</span>
|
||||
<span class="value">{{ gameId }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -15,6 +19,37 @@
|
||||
<section v-if="step === 'setup'" class="panel">
|
||||
<h2>Team Setup</h2>
|
||||
<p class="hint">Add teams, name them, and create their color gradients.</p>
|
||||
<div class="session-box">
|
||||
<h3>Live Session</h3>
|
||||
<p class="hint">
|
||||
Create a game ID as host, or join an existing game ID as viewer.
|
||||
</p>
|
||||
<div class="session-row">
|
||||
<input
|
||||
v-model.trim="gameIdInput"
|
||||
class="input"
|
||||
placeholder="Game ID"
|
||||
:disabled="sessionBusy || !!gameId"
|
||||
/>
|
||||
<button class="ghost" @click="createSession()" :disabled="sessionBusy || !!gameId || !normalizedGameIdInput">
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
class="ghost"
|
||||
@click="joinSession"
|
||||
:disabled="sessionBusy || !!gameId || !normalizedGameIdInput"
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
<button class="ghost" v-if="gameId" @click="leaveSession" :disabled="sessionBusy">
|
||||
Leave
|
||||
</button>
|
||||
</div>
|
||||
<p class="session-meta">
|
||||
<span>Status: {{ connectionLabel }}</span>
|
||||
<span v-if="syncError">Error: {{ syncError }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="teams-grid">
|
||||
<div v-for="team in teams" :key="team.id" class="team-card">
|
||||
@@ -23,28 +58,29 @@
|
||||
v-model="team.name"
|
||||
class="input"
|
||||
placeholder="Team name"
|
||||
:disabled="!canControlGame"
|
||||
/>
|
||||
<button class="ghost" @click="removeTeam(team.id)">Remove</button>
|
||||
<button class="ghost" @click="removeTeam(team.id)" :disabled="!canControlGame">Remove</button>
|
||||
</div>
|
||||
<div class="gradient-preview" :style="teamGradient(team)"></div>
|
||||
<div class="color-row">
|
||||
<label>
|
||||
Color A
|
||||
<input v-model="team.colorA" type="color" />
|
||||
<input v-model="team.colorA" type="color" :disabled="!canControlGame" />
|
||||
</label>
|
||||
<label>
|
||||
Color B
|
||||
<input v-model="team.colorB" type="color" />
|
||||
<input v-model="team.colorB" type="color" :disabled="!canControlGame" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="add-card" @click="addTeam">
|
||||
<button class="add-card" @click="addTeam" :disabled="!canControlGame">
|
||||
<span>+ Add Team</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" :disabled="!canProceed" @click="goToSelect">
|
||||
<button class="primary" :disabled="!canProceedToSelect" @click="goToSelect">
|
||||
Next: Choose Game
|
||||
</button>
|
||||
</div>
|
||||
@@ -53,12 +89,12 @@
|
||||
<section v-if="step === 'select'" class="panel">
|
||||
<h2>Select a Game</h2>
|
||||
<p class="hint">
|
||||
Games are loaded from <code>src/Data</code>. Each subfolder becomes a
|
||||
Games are loaded from <code>public/Data</code>. Each subfolder becomes a
|
||||
playable game.
|
||||
</p>
|
||||
|
||||
<div v-if="loadingGames" class="empty-state">
|
||||
<p>Loading games…</p>
|
||||
<p>Loading games...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loadError" class="empty-state">
|
||||
@@ -81,6 +117,7 @@
|
||||
:key="game.name"
|
||||
class="game-card"
|
||||
@click="startGame(game)"
|
||||
:disabled="!canControlGame"
|
||||
>
|
||||
<h3>{{ game.name }}</h3>
|
||||
<p>{{ game.categories.length }} categories</p>
|
||||
@@ -88,7 +125,7 @@
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="ghost" @click="step = 'setup'">Back</button>
|
||||
<button class="ghost" @click="step = 'setup'" :disabled="!canControlGame">Back</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -103,7 +140,7 @@
|
||||
:class="{ active: team.id === currentSelectorId }"
|
||||
:style="teamGradient(team)"
|
||||
@click="awardPoints(team.id)"
|
||||
:disabled="!canAward"
|
||||
:disabled="!canAward || !canControlGame"
|
||||
>
|
||||
<div class="team-name">{{ team.name }}</div>
|
||||
<div class="team-score">{{ team.score }}</div>
|
||||
@@ -113,7 +150,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button class="ghost" @click="resetGame">Reset Game</button>
|
||||
<button class="ghost" @click="resetGame" :disabled="!canControlGame">Reset Game</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -131,7 +168,7 @@
|
||||
class="tile"
|
||||
:class="tileClass(cIndex, qIndex)"
|
||||
:style="tileStyle(cIndex, qIndex)"
|
||||
:disabled="tileDisabled(cIndex, qIndex)"
|
||||
:disabled="tileDisabled(cIndex, qIndex) || !canControlGame"
|
||||
@click="handleTileClick(cIndex, qIndex)"
|
||||
@contextmenu.prevent="handleTileRightClick(cIndex, qIndex)"
|
||||
>
|
||||
@@ -252,6 +289,8 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { loadGameData, type Game } from './dataLoader'
|
||||
|
||||
type Step = 'setup' | 'select' | 'game' | 'end'
|
||||
|
||||
type Team = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -267,6 +306,25 @@ type TileEntry = {
|
||||
|
||||
type TileMap = Record<string, TileEntry>
|
||||
|
||||
type RealtimeState = {
|
||||
step: Step
|
||||
teams: Team[]
|
||||
selectedGameName: string | null
|
||||
tiles: TileMap
|
||||
currentTileKey: string | null
|
||||
currentClipUrl: string
|
||||
currentSelectorId: string | null
|
||||
lastAwardedTeamId: string | null
|
||||
isAnswerClip: boolean
|
||||
}
|
||||
|
||||
type RealtimeMessage = {
|
||||
type: 'session:init' | 'state:update'
|
||||
state: RealtimeState | null
|
||||
}
|
||||
|
||||
const REALTIME_BASE = '/realtime'
|
||||
|
||||
const makeTeam = (index: number): Team => ({
|
||||
id: `team-${Date.now()}-${index}`,
|
||||
name: `Team ${index + 1}`,
|
||||
@@ -275,20 +333,27 @@ const makeTeam = (index: number): Team => ({
|
||||
score: 0
|
||||
})
|
||||
|
||||
const makeClientId = () => {
|
||||
const segment = Math.random().toString(36).slice(2, 10)
|
||||
return `client-${Date.now()}-${segment}`
|
||||
}
|
||||
|
||||
const cloneTiles = (tiles: TileMap): TileMap => JSON.parse(JSON.stringify(tiles)) as TileMap
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
step: 'setup',
|
||||
step: 'setup' as Step,
|
||||
teams: [makeTeam(0), makeTeam(1)] as Team[],
|
||||
games: [] as Game[],
|
||||
loadingGames: true,
|
||||
loadError: '',
|
||||
selectedGame: null as Game | null,
|
||||
tiles: {} as TileMap,
|
||||
currentTileKey: null,
|
||||
currentTileKey: null as string | null,
|
||||
currentClipUrl: '',
|
||||
currentSelectorId: null,
|
||||
lastAwardedTeamId: null,
|
||||
currentSelectorId: null as string | null,
|
||||
lastAwardedTeamId: null as string | null,
|
||||
isPlaying: false,
|
||||
pulseLevel: 0,
|
||||
audioContext: null as AudioContext | null,
|
||||
@@ -301,13 +366,33 @@ export default {
|
||||
rayLevel: 0,
|
||||
rayHue: 200,
|
||||
isAnswerClip: false,
|
||||
tentacleLevels: Array.from({ length: 12 }, () => 0)
|
||||
tentacleLevels: Array.from({ length: 12 }, () => 0),
|
||||
gameId: '',
|
||||
gameIdInput: '',
|
||||
sessionBusy: false,
|
||||
socket: null as WebSocket | null,
|
||||
socketConnected: false,
|
||||
isHost: false,
|
||||
clientId: makeClientId(),
|
||||
syncError: '',
|
||||
isApplyingRemote: false,
|
||||
queuedRemoteState: null as RealtimeState | null,
|
||||
syncTimer: 0
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
try {
|
||||
this.loadingGames = true
|
||||
this.games = await loadGameData()
|
||||
if (this.queuedRemoteState) {
|
||||
await this.applyRemoteState(this.queuedRemoteState)
|
||||
this.queuedRemoteState = null
|
||||
}
|
||||
const fromUrl = new URL(window.location.href).searchParams.get('game')
|
||||
if (fromUrl) {
|
||||
this.gameIdInput = fromUrl.toUpperCase()
|
||||
await this.joinSession()
|
||||
}
|
||||
} catch (error) {
|
||||
this.loadError = error instanceof Error ? error.message : 'Failed to load games.'
|
||||
} finally {
|
||||
@@ -315,9 +400,18 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
normalizedGameIdInput() {
|
||||
return this.normalizeGameId(this.gameIdInput)
|
||||
},
|
||||
canProceed() {
|
||||
return this.teams.length > 0 && this.teams.every((team) => team.name.trim())
|
||||
},
|
||||
canProceedToSelect() {
|
||||
return this.canProceed && this.canControlGame && !!this.normalizedGameIdInput && !this.sessionBusy
|
||||
},
|
||||
canControlGame() {
|
||||
return !this.gameId || this.isHost
|
||||
},
|
||||
currentSelector() {
|
||||
return this.teams.find((team) => team.id === this.currentSelectorId) || null
|
||||
},
|
||||
@@ -333,6 +427,12 @@ export default {
|
||||
.filter((team) => this.winnerIds.includes(team.id))
|
||||
.map((team) => team.name)
|
||||
.join(', ')
|
||||
},
|
||||
connectionLabel() {
|
||||
if (!this.gameId) return 'Local mode'
|
||||
if (this.sessionBusy) return 'Connecting'
|
||||
if (!this.socketConnected) return this.isHost ? 'Host disconnected' : 'Viewer disconnected'
|
||||
return this.isHost ? 'Connected as host' : 'Connected as viewer'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -345,6 +445,190 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
normalizeGameId(value: string) {
|
||||
return value.toUpperCase().replace(/[^A-Z0-9]/g, '')
|
||||
},
|
||||
setGameInUrl(gameId: string) {
|
||||
const url = new URL(window.location.href)
|
||||
if (gameId) {
|
||||
url.searchParams.set('game', gameId)
|
||||
} else {
|
||||
url.searchParams.delete('game')
|
||||
}
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
},
|
||||
closeSocket() {
|
||||
if (this.socket) {
|
||||
this.socket.close()
|
||||
this.socket = null
|
||||
}
|
||||
this.socketConnected = false
|
||||
},
|
||||
async createSession(preferredId?: string) {
|
||||
if (this.sessionBusy || this.gameId) return
|
||||
const requestedId = this.normalizeGameId(preferredId || this.gameIdInput)
|
||||
if (!requestedId) {
|
||||
this.syncError = 'Game ID is required'
|
||||
return
|
||||
}
|
||||
this.sessionBusy = true
|
||||
this.syncError = ''
|
||||
try {
|
||||
const response = await fetch(`${REALTIME_BASE}/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ gameId: requestedId })
|
||||
})
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to create session'
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string }
|
||||
if (payload?.error) errorMessage = payload.error
|
||||
} catch {
|
||||
// ignore parse errors and keep default message
|
||||
}
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
const payload = (await response.json()) as { gameId: string }
|
||||
const gameId = this.normalizeGameId(payload.gameId)
|
||||
this.gameId = gameId
|
||||
this.gameIdInput = gameId
|
||||
this.isHost = true
|
||||
this.setGameInUrl(gameId)
|
||||
await this.connectSession()
|
||||
} catch (error) {
|
||||
this.syncError = error instanceof Error ? error.message : 'Failed to create session'
|
||||
} finally {
|
||||
this.sessionBusy = false
|
||||
}
|
||||
},
|
||||
async joinSession() {
|
||||
if (this.sessionBusy || this.gameId) return
|
||||
const normalized = this.normalizeGameId(this.gameIdInput)
|
||||
if (!normalized) return
|
||||
this.sessionBusy = true
|
||||
this.syncError = ''
|
||||
try {
|
||||
const existsResponse = await fetch(`${REALTIME_BASE}/sessions/${normalized}`)
|
||||
if (!existsResponse.ok) throw new Error('Session not found')
|
||||
this.gameId = normalized
|
||||
this.gameIdInput = normalized
|
||||
this.isHost = false
|
||||
this.setGameInUrl(normalized)
|
||||
await this.connectSession()
|
||||
} catch (error) {
|
||||
this.syncError = error instanceof Error ? error.message : 'Failed to join session'
|
||||
} finally {
|
||||
this.sessionBusy = false
|
||||
}
|
||||
},
|
||||
leaveSession() {
|
||||
this.closeSocket()
|
||||
this.gameId = ''
|
||||
this.gameIdInput = ''
|
||||
this.isHost = false
|
||||
this.syncError = ''
|
||||
this.setGameInUrl('')
|
||||
},
|
||||
async connectSession() {
|
||||
this.closeSocket()
|
||||
if (!this.gameId) return
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${protocol}//${window.location.host}${REALTIME_BASE}/ws?gameId=${encodeURIComponent(
|
||||
this.gameId
|
||||
)}&clientId=${encodeURIComponent(this.clientId)}`
|
||||
const socket = new WebSocket(wsUrl)
|
||||
this.socket = socket
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.onopen = () => {
|
||||
if (this.socket !== socket) return
|
||||
this.socketConnected = true
|
||||
this.syncError = ''
|
||||
if (this.isHost) this.publishState()
|
||||
resolve()
|
||||
}
|
||||
socket.onerror = () => {
|
||||
if (this.socket !== socket) return
|
||||
this.socketConnected = false
|
||||
this.syncError = 'Realtime connection failed'
|
||||
reject(new Error('Realtime connection failed'))
|
||||
}
|
||||
}).catch(() => {})
|
||||
|
||||
socket.onmessage = async (event) => {
|
||||
let message: RealtimeMessage | null = null
|
||||
try {
|
||||
message = JSON.parse(event.data as string) as RealtimeMessage
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!message) return
|
||||
if (message.type === 'session:init' || message.type === 'state:update') {
|
||||
if (message.state) await this.applyRemoteState(message.state)
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
if (this.socket === socket) {
|
||||
this.socketConnected = false
|
||||
}
|
||||
}
|
||||
},
|
||||
buildRealtimeState(): RealtimeState {
|
||||
return {
|
||||
step: this.step,
|
||||
teams: this.teams.map((team) => ({ ...team })),
|
||||
selectedGameName: this.selectedGame?.name || null,
|
||||
tiles: cloneTiles(this.tiles),
|
||||
currentTileKey: this.currentTileKey,
|
||||
currentClipUrl: this.currentClipUrl,
|
||||
currentSelectorId: this.currentSelectorId,
|
||||
lastAwardedTeamId: this.lastAwardedTeamId,
|
||||
isAnswerClip: this.isAnswerClip
|
||||
}
|
||||
},
|
||||
publishState() {
|
||||
if (!this.isHost || !this.socketConnected || !this.socket) return
|
||||
if (this.isApplyingRemote) return
|
||||
const payload = {
|
||||
type: 'state:update',
|
||||
state: this.buildRealtimeState()
|
||||
}
|
||||
this.socket.send(JSON.stringify(payload))
|
||||
},
|
||||
queueStateSync() {
|
||||
if (!this.isHost || !this.socketConnected || !this.socket) return
|
||||
if (this.isApplyingRemote) return
|
||||
if (this.syncTimer) window.clearTimeout(this.syncTimer)
|
||||
this.syncTimer = window.setTimeout(() => {
|
||||
this.publishState()
|
||||
}, 80)
|
||||
},
|
||||
async applyRemoteState(state: RealtimeState) {
|
||||
if (this.isHost) return
|
||||
if (this.loadingGames) {
|
||||
this.queuedRemoteState = state
|
||||
return
|
||||
}
|
||||
this.isApplyingRemote = true
|
||||
try {
|
||||
const selected = state.selectedGameName
|
||||
? this.games.find((game) => game.name === state.selectedGameName) || null
|
||||
: null
|
||||
this.step = state.step
|
||||
this.teams = state.teams.map((team) => ({ ...team }))
|
||||
this.selectedGame = selected
|
||||
this.tiles = cloneTiles(state.tiles)
|
||||
this.currentTileKey = state.currentTileKey
|
||||
this.currentClipUrl = state.currentClipUrl
|
||||
this.currentSelectorId = state.currentSelectorId
|
||||
this.lastAwardedTeamId = state.lastAwardedTeamId
|
||||
this.isAnswerClip = state.isAnswerClip
|
||||
} finally {
|
||||
this.isApplyingRemote = false
|
||||
}
|
||||
},
|
||||
getPlayer() {
|
||||
return this.$refs.player as HTMLAudioElement | undefined
|
||||
},
|
||||
@@ -496,16 +780,32 @@ export default {
|
||||
}
|
||||
},
|
||||
addTeam() {
|
||||
if (!this.canControlGame) return
|
||||
this.teams.push(makeTeam(this.teams.length))
|
||||
this.queueStateSync()
|
||||
},
|
||||
removeTeam(id: string) {
|
||||
if (!this.canControlGame) return
|
||||
if (this.teams.length === 1) return
|
||||
this.teams = this.teams.filter((team) => team.id !== id)
|
||||
this.queueStateSync()
|
||||
},
|
||||
goToSelect() {
|
||||
async goToSelect() {
|
||||
if (!this.canControlGame) return
|
||||
const requestedId = this.normalizeGameId(this.gameIdInput)
|
||||
if (!requestedId) {
|
||||
this.syncError = 'Game ID is required'
|
||||
return
|
||||
}
|
||||
if (!this.gameId) {
|
||||
await this.createSession(requestedId)
|
||||
if (!this.gameId) return
|
||||
}
|
||||
this.step = 'select'
|
||||
this.queueStateSync()
|
||||
},
|
||||
startGame(game: Game) {
|
||||
if (!this.canControlGame) return
|
||||
this.selectedGame = game
|
||||
this.tiles = {}
|
||||
this.currentTileKey = null
|
||||
@@ -515,8 +815,10 @@ export default {
|
||||
const randomTeam = this.teams[Math.floor(Math.random() * this.teams.length)]
|
||||
this.currentSelectorId = randomTeam?.id || null
|
||||
this.step = 'game'
|
||||
this.queueStateSync()
|
||||
},
|
||||
resetGame() {
|
||||
if (!this.canControlGame) return
|
||||
this.step = 'select'
|
||||
this.selectedGame = null
|
||||
this.tiles = {}
|
||||
@@ -525,6 +827,7 @@ export default {
|
||||
this.lastAwardedTeamId = null
|
||||
this.isAnswerClip = false
|
||||
this.teardownAudio()
|
||||
this.queueStateSync()
|
||||
},
|
||||
tileKey(cIndex: number, qIndex: number) {
|
||||
return `${cIndex}-${qIndex}`
|
||||
@@ -558,6 +861,7 @@ export default {
|
||||
return this.teamGradient(team)
|
||||
},
|
||||
async handleTileClick(cIndex: number, qIndex: number) {
|
||||
if (!this.canControlGame) return
|
||||
const key = this.tileKey(cIndex, qIndex)
|
||||
const status = this.tileStatus(cIndex, qIndex)
|
||||
const clue = this.selectedGame?.categories[cIndex].clues[qIndex]
|
||||
@@ -577,6 +881,7 @@ export default {
|
||||
player.load()
|
||||
player.play().catch(() => {})
|
||||
}
|
||||
this.queueStateSync()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -584,6 +889,7 @@ export default {
|
||||
this.tiles[key].status = 'paused'
|
||||
const player = this.getPlayer()
|
||||
player?.pause()
|
||||
this.queueStateSync()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -602,6 +908,7 @@ export default {
|
||||
player.play().catch(() => {})
|
||||
}
|
||||
}
|
||||
this.queueStateSync()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -614,9 +921,11 @@ export default {
|
||||
this.currentClipUrl = ''
|
||||
this.lastAwardedTeamId = null
|
||||
this.checkEnd()
|
||||
this.queueStateSync()
|
||||
}
|
||||
},
|
||||
handleTileRightClick(cIndex: number, qIndex: number) {
|
||||
if (!this.canControlGame) return
|
||||
const key = this.tileKey(cIndex, qIndex)
|
||||
const status = this.tileStatus(cIndex, qIndex)
|
||||
if (key !== this.currentTileKey) return
|
||||
@@ -625,6 +934,7 @@ export default {
|
||||
this.tiles[key].status = 'playing'
|
||||
const player = this.getPlayer()
|
||||
player?.play().catch(() => {})
|
||||
this.queueStateSync()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -637,9 +947,11 @@ export default {
|
||||
player?.pause()
|
||||
this.teardownAudio()
|
||||
this.checkEnd()
|
||||
this.queueStateSync()
|
||||
}
|
||||
},
|
||||
awardPoints(teamId: string) {
|
||||
if (!this.canControlGame) return
|
||||
if (!this.canAward) return
|
||||
const key = this.currentTileKey
|
||||
if (!key || !this.selectedGame) return
|
||||
@@ -652,6 +964,7 @@ export default {
|
||||
if (this.tiles[key]) {
|
||||
this.tiles[key].lastTeamId = teamId
|
||||
}
|
||||
this.queueStateSync()
|
||||
},
|
||||
checkEnd() {
|
||||
if (!this.selectedGame) return
|
||||
@@ -669,6 +982,9 @@ export default {
|
||||
},
|
||||
unmounted() {
|
||||
this.teardownAudio()
|
||||
this.closeSocket()
|
||||
if (this.syncTimer) window.clearTimeout(this.syncTimer)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -72,6 +72,10 @@ code {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.session-pill {
|
||||
border-color: rgba(61, 214, 255, 0.5);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -96,6 +100,38 @@ code {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.session-box {
|
||||
margin-top: 18px;
|
||||
margin-bottom: 20px;
|
||||
padding: 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.session-box h3 {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.session-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.session-row .input {
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
margin: 12px 0 0;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.teams-grid,
|
||||
.games-grid {
|
||||
display: grid;
|
||||
@@ -171,6 +207,11 @@ input[type='color'] {
|
||||
background: linear-gradient(135deg, rgba(61, 214, 255, 0.12), rgba(251, 215, 43, 0.12));
|
||||
}
|
||||
|
||||
.add-card:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.game-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
@@ -534,4 +575,8 @@ audio.hidden-audio {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.session-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user