Init
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
18
index.html
Normal file
18
index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Music Jeopardy</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Space+Grotesk:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1535
package-lock.json
generated
Normal file
1535
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "music-jeopardy",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.2.47"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^2.3.4",
|
||||||
|
"vite": "^2.9.16",
|
||||||
|
"typescript": "^4.9.5",
|
||||||
|
"vue-tsc": "^1.0.24"
|
||||||
|
}
|
||||||
|
}
|
||||||
435
src/App.vue
Normal file
435
src/App.vue
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app">
|
||||||
|
<header class="app-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Music Jeopardy</p>
|
||||||
|
<h1>Track the Beat, Claim the Points</h1>
|
||||||
|
</div>
|
||||||
|
<div v-if="step === 'game'" class="selector-pill">
|
||||||
|
<span class="label">Selecting</span>
|
||||||
|
<span class="value">{{ currentSelector?.name || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-main">
|
||||||
|
<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="teams-grid">
|
||||||
|
<div v-for="team in teams" :key="team.id" class="team-card">
|
||||||
|
<div class="team-card-header">
|
||||||
|
<input
|
||||||
|
v-model="team.name"
|
||||||
|
class="input"
|
||||||
|
placeholder="Team name"
|
||||||
|
/>
|
||||||
|
<button class="ghost" @click="removeTeam(team.id)">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" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Color B
|
||||||
|
<input v-model="team.colorB" type="color" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="add-card" @click="addTeam">
|
||||||
|
<span>+ Add Team</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" :disabled="!canProceed" @click="goToSelect">
|
||||||
|
Next: Choose Game
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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
|
||||||
|
playable game.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="games.length === 0" class="empty-state">
|
||||||
|
<p>No games found yet.</p>
|
||||||
|
<p>
|
||||||
|
Add folders in <code>src/Data/<GameName>/<Category></code>
|
||||||
|
with <code>Songs</code> and <code>Answers</code> subfolders.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="games-grid" v-else>
|
||||||
|
<button
|
||||||
|
v-for="game in games"
|
||||||
|
:key="game.name"
|
||||||
|
class="game-card"
|
||||||
|
@click="startGame(game)"
|
||||||
|
>
|
||||||
|
<h3>{{ game.name }}</h3>
|
||||||
|
<p>{{ game.categories.length }} categories</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="ghost" @click="step = 'setup'">Back</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="step === 'game'" class="game-layout">
|
||||||
|
<aside class="scoreboard">
|
||||||
|
<h2>Teams</h2>
|
||||||
|
<div class="team-list">
|
||||||
|
<button
|
||||||
|
v-for="team in teams"
|
||||||
|
:key="team.id"
|
||||||
|
class="team-score-card"
|
||||||
|
:class="{ active: team.id === currentSelectorId }"
|
||||||
|
:style="teamGradient(team)"
|
||||||
|
@click="awardPoints(team.id)"
|
||||||
|
:disabled="!canAward"
|
||||||
|
>
|
||||||
|
<div class="team-name">{{ team.name }}</div>
|
||||||
|
<div class="team-score">{{ team.score }}</div>
|
||||||
|
<div v-if="team.id === lastAwardedTeamId" class="award-tag">
|
||||||
|
Last Award
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<button class="ghost" @click="resetGame">Reset Game</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="board">
|
||||||
|
<div class="board-grid">
|
||||||
|
<div
|
||||||
|
v-for="(category, cIndex) in selectedGame?.categories || []"
|
||||||
|
:key="category.name"
|
||||||
|
class="category-column"
|
||||||
|
>
|
||||||
|
<div class="category-title">{{ category.name }}</div>
|
||||||
|
<button
|
||||||
|
v-for="(clue, qIndex) in category.clues"
|
||||||
|
:key="clue.points"
|
||||||
|
class="tile"
|
||||||
|
:class="tileClass(cIndex, qIndex)"
|
||||||
|
:style="tileStyle(cIndex, qIndex)"
|
||||||
|
:disabled="tileDisabled(cIndex, qIndex)"
|
||||||
|
@click="handleTileClick(cIndex, qIndex)"
|
||||||
|
@contextmenu.prevent="handleTileRightClick(cIndex, qIndex)"
|
||||||
|
>
|
||||||
|
<span v-if="tileStatus(cIndex, qIndex) === 'available'">
|
||||||
|
{{ clue.points }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="tileStatus(cIndex, qIndex) === 'playing'">
|
||||||
|
Playing
|
||||||
|
</span>
|
||||||
|
<span v-else-if="tileStatus(cIndex, qIndex) === 'paused'">
|
||||||
|
Paused
|
||||||
|
</span>
|
||||||
|
<span v-else-if="tileStatus(cIndex, qIndex) === 'guessed'">
|
||||||
|
Guessed
|
||||||
|
</span>
|
||||||
|
<span v-else-if="tileStatus(cIndex, qIndex) === 'won'">
|
||||||
|
Won
|
||||||
|
</span>
|
||||||
|
<span v-else>Skipped</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="player-panel">
|
||||||
|
<div class="player-header">
|
||||||
|
<h3>Now Playing</h3>
|
||||||
|
<p>
|
||||||
|
Left click the active tile to pause/guess. Right click to resume
|
||||||
|
or skip.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="player-body">
|
||||||
|
<audio
|
||||||
|
ref="player"
|
||||||
|
v-if="currentClipUrl"
|
||||||
|
:src="currentClipUrl"
|
||||||
|
controls
|
||||||
|
></audio>
|
||||||
|
<div v-else class="player-empty">
|
||||||
|
Pick a tile to start the round.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="step === 'end'" class="panel end-panel">
|
||||||
|
<h2>Final Scores</h2>
|
||||||
|
<div class="winner-banner">
|
||||||
|
<span>Winner</span>
|
||||||
|
<strong>{{ winnerNames }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="teams-grid">
|
||||||
|
<div
|
||||||
|
v-for="team in teams"
|
||||||
|
:key="team.id"
|
||||||
|
class="team-card"
|
||||||
|
:class="{ winner: winnerIds.includes(team.id) }"
|
||||||
|
>
|
||||||
|
<div class="gradient-preview" :style="teamGradient(team)"></div>
|
||||||
|
<h3>{{ team.name }}</h3>
|
||||||
|
<p class="score">{{ team.score }} pts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" @click="step = 'setup'">
|
||||||
|
Start New Game
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { gameData, type Game } from './dataLoader'
|
||||||
|
|
||||||
|
type Team = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
colorA: string
|
||||||
|
colorB: string
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type TileEntry = {
|
||||||
|
status: 'available' | 'playing' | 'paused' | 'guessed' | 'won' | 'void'
|
||||||
|
lastTeamId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type TileMap = Record<string, TileEntry>
|
||||||
|
|
||||||
|
const makeTeam = (index: number): Team => ({
|
||||||
|
id: `team-${Date.now()}-${index}`,
|
||||||
|
name: `Team ${index + 1}`,
|
||||||
|
colorA: '#3dd6ff',
|
||||||
|
colorB: '#fbd72b',
|
||||||
|
score: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
step: 'setup',
|
||||||
|
teams: [makeTeam(0), makeTeam(1)] as Team[],
|
||||||
|
games: gameData as Game[],
|
||||||
|
selectedGame: null as Game | null,
|
||||||
|
tiles: {} as TileMap,
|
||||||
|
currentTileKey: null,
|
||||||
|
currentClipUrl: '',
|
||||||
|
currentSelectorId: null,
|
||||||
|
lastAwardedTeamId: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
canProceed() {
|
||||||
|
return this.teams.length > 0 && this.teams.every((team) => team.name.trim())
|
||||||
|
},
|
||||||
|
currentSelector() {
|
||||||
|
return this.teams.find((team) => team.id === this.currentSelectorId) || null
|
||||||
|
},
|
||||||
|
canAward() {
|
||||||
|
return !!this.currentTileKey && this.tiles[this.currentTileKey]?.status === 'guessed'
|
||||||
|
},
|
||||||
|
winnerIds() {
|
||||||
|
const maxScore = Math.max(...this.teams.map((team) => team.score))
|
||||||
|
return this.teams.filter((team) => team.score === maxScore).map((team) => team.id)
|
||||||
|
},
|
||||||
|
winnerNames() {
|
||||||
|
return this.teams
|
||||||
|
.filter((team) => this.winnerIds.includes(team.id))
|
||||||
|
.map((team) => team.name)
|
||||||
|
.join(', ')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getPlayer() {
|
||||||
|
return this.$refs.player as HTMLAudioElement | undefined
|
||||||
|
},
|
||||||
|
teamGradient(team: Team) {
|
||||||
|
return {
|
||||||
|
background: `linear-gradient(135deg, ${team.colorA}, ${team.colorB})`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addTeam() {
|
||||||
|
this.teams.push(makeTeam(this.teams.length))
|
||||||
|
},
|
||||||
|
removeTeam(id: string) {
|
||||||
|
if (this.teams.length === 1) return
|
||||||
|
this.teams = this.teams.filter((team) => team.id !== id)
|
||||||
|
},
|
||||||
|
goToSelect() {
|
||||||
|
this.step = 'select'
|
||||||
|
},
|
||||||
|
startGame(game: Game) {
|
||||||
|
this.selectedGame = game
|
||||||
|
this.tiles = {}
|
||||||
|
this.currentTileKey = null
|
||||||
|
this.currentClipUrl = ''
|
||||||
|
this.lastAwardedTeamId = null
|
||||||
|
this.teams = this.teams.map((team) => ({ ...team, score: 0 }))
|
||||||
|
const randomTeam = this.teams[Math.floor(Math.random() * this.teams.length)]
|
||||||
|
this.currentSelectorId = randomTeam?.id || null
|
||||||
|
this.step = 'game'
|
||||||
|
},
|
||||||
|
resetGame() {
|
||||||
|
this.step = 'select'
|
||||||
|
this.selectedGame = null
|
||||||
|
this.tiles = {}
|
||||||
|
this.currentTileKey = null
|
||||||
|
this.currentClipUrl = ''
|
||||||
|
this.lastAwardedTeamId = null
|
||||||
|
},
|
||||||
|
tileKey(cIndex: number, qIndex: number) {
|
||||||
|
return `${cIndex}-${qIndex}`
|
||||||
|
},
|
||||||
|
tileStatus(cIndex: number, qIndex: number) {
|
||||||
|
const key = this.tileKey(cIndex, qIndex)
|
||||||
|
return this.tiles[key]?.status || 'available'
|
||||||
|
},
|
||||||
|
tileDisabled(cIndex: number, qIndex: number) {
|
||||||
|
const status = this.tileStatus(cIndex, qIndex)
|
||||||
|
if (status === 'won' || status === 'void') return true
|
||||||
|
if (!this.currentTileKey) return false
|
||||||
|
return this.currentTileKey !== this.tileKey(cIndex, qIndex)
|
||||||
|
},
|
||||||
|
tileClass(cIndex: number, qIndex: number) {
|
||||||
|
const status = this.tileStatus(cIndex, qIndex)
|
||||||
|
return {
|
||||||
|
playing: status === 'playing',
|
||||||
|
paused: status === 'paused',
|
||||||
|
guessed: status === 'guessed',
|
||||||
|
won: status === 'won',
|
||||||
|
void: status === 'void'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tileStyle(cIndex: number, qIndex: number) {
|
||||||
|
const key = this.tileKey(cIndex, qIndex)
|
||||||
|
const entry = this.tiles[key]
|
||||||
|
if (entry?.status !== 'won' || !entry.lastTeamId) return {}
|
||||||
|
const team = this.teams.find((t) => t.id === entry.lastTeamId)
|
||||||
|
if (!team) return {}
|
||||||
|
return this.teamGradient(team)
|
||||||
|
},
|
||||||
|
async handleTileClick(cIndex: number, qIndex: number) {
|
||||||
|
const key = this.tileKey(cIndex, qIndex)
|
||||||
|
const status = this.tileStatus(cIndex, qIndex)
|
||||||
|
const clue = this.selectedGame?.categories[cIndex].clues[qIndex]
|
||||||
|
|
||||||
|
if (!clue || !clue.song) return
|
||||||
|
|
||||||
|
if (status === 'available') {
|
||||||
|
this.tiles[key] = { status: 'playing', lastTeamId: null }
|
||||||
|
this.currentTileKey = key
|
||||||
|
this.currentClipUrl = encodeURI(clue.song)
|
||||||
|
await nextTick()
|
||||||
|
const player = this.getPlayer()
|
||||||
|
if (player) {
|
||||||
|
player.currentTime = 0
|
||||||
|
player.load()
|
||||||
|
player.play().catch(() => {})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'playing') {
|
||||||
|
this.tiles[key].status = 'paused'
|
||||||
|
const player = this.getPlayer()
|
||||||
|
player?.pause()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'paused') {
|
||||||
|
this.tiles[key].status = 'guessed'
|
||||||
|
this.lastAwardedTeamId = null
|
||||||
|
if (clue.answer) {
|
||||||
|
this.currentClipUrl = encodeURI(clue.answer)
|
||||||
|
await nextTick()
|
||||||
|
const player = this.getPlayer()
|
||||||
|
if (player) {
|
||||||
|
player.currentTime = 0
|
||||||
|
player.load()
|
||||||
|
player.play().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'guessed') {
|
||||||
|
if (!this.lastAwardedTeamId) return
|
||||||
|
this.tiles[key].status = 'won'
|
||||||
|
this.tiles[key].lastTeamId = this.lastAwardedTeamId
|
||||||
|
this.currentSelectorId = this.lastAwardedTeamId
|
||||||
|
this.currentTileKey = null
|
||||||
|
this.currentClipUrl = ''
|
||||||
|
this.lastAwardedTeamId = null
|
||||||
|
this.checkEnd()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleTileRightClick(cIndex: number, qIndex: number) {
|
||||||
|
const key = this.tileKey(cIndex, qIndex)
|
||||||
|
const status = this.tileStatus(cIndex, qIndex)
|
||||||
|
if (key !== this.currentTileKey) return
|
||||||
|
|
||||||
|
if (status === 'paused') {
|
||||||
|
this.tiles[key].status = 'playing'
|
||||||
|
const player = this.getPlayer()
|
||||||
|
player?.play().catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'playing') {
|
||||||
|
this.tiles[key].status = 'void'
|
||||||
|
this.currentTileKey = null
|
||||||
|
this.currentClipUrl = ''
|
||||||
|
const player = this.getPlayer()
|
||||||
|
player?.pause()
|
||||||
|
this.checkEnd()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
awardPoints(teamId: string) {
|
||||||
|
if (!this.canAward) return
|
||||||
|
const key = this.currentTileKey
|
||||||
|
if (!key || !this.selectedGame) return
|
||||||
|
const [cIndex, qIndex] = key.split('-').map(Number)
|
||||||
|
const clue = this.selectedGame.categories[cIndex].clues[qIndex]
|
||||||
|
this.teams = this.teams.map((team) =>
|
||||||
|
team.id === teamId ? { ...team, score: team.score + clue.points } : team
|
||||||
|
)
|
||||||
|
this.lastAwardedTeamId = teamId
|
||||||
|
if (this.tiles[key]) {
|
||||||
|
this.tiles[key].lastTeamId = teamId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
checkEnd() {
|
||||||
|
if (!this.selectedGame) return
|
||||||
|
const allTiles: TileEntry['status'][] = []
|
||||||
|
this.selectedGame.categories.forEach((category, cIndex) => {
|
||||||
|
category.clues.forEach((_, qIndex) => {
|
||||||
|
allTiles.push(this.tileStatus(cIndex, qIndex))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const finished = allTiles.every((status) => status === 'won' || status === 'void')
|
||||||
|
if (finished) {
|
||||||
|
this.step = 'end'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
1
src/Data/.gitkeep
Normal file
1
src/Data/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
BIN
src/Data/Jeff/Billboard Number 1's/Answers/1.mp3
Normal file
BIN
src/Data/Jeff/Billboard Number 1's/Answers/1.mp3
Normal file
Binary file not shown.
BIN
src/Data/Jeff/Billboard Number 1's/Answers/2.mp3
Normal file
BIN
src/Data/Jeff/Billboard Number 1's/Answers/2.mp3
Normal file
Binary file not shown.
BIN
src/Data/Jeff/Billboard Number 1's/Answers/3.mp3
Normal file
BIN
src/Data/Jeff/Billboard Number 1's/Answers/3.mp3
Normal file
Binary file not shown.
BIN
src/Data/Jeff/Billboard Number 1's/Answers/4.mp3
Normal file
BIN
src/Data/Jeff/Billboard Number 1's/Answers/4.mp3
Normal file
Binary file not shown.
BIN
src/Data/Jeff/Billboard Number 1's/Answers/5.mp3
Normal file
BIN
src/Data/Jeff/Billboard Number 1's/Answers/5.mp3
Normal file
Binary file not shown.
BIN
src/Data/Jeff/Billboard Number 1's/Songs/1.mp3
Normal file
BIN
src/Data/Jeff/Billboard Number 1's/Songs/1.mp3
Normal file
Binary file not shown.
BIN
src/Data/Jeff/Billboard Number 1's/Songs/2.mp3
Normal file
BIN
src/Data/Jeff/Billboard Number 1's/Songs/2.mp3
Normal file
Binary file not shown.
BIN
src/Data/Jeff/Billboard Number 1's/Songs/3.mp3
Normal file
BIN
src/Data/Jeff/Billboard Number 1's/Songs/3.mp3
Normal file
Binary file not shown.
BIN
src/Data/Jeff/Billboard Number 1's/Songs/4.mp3
Normal file
BIN
src/Data/Jeff/Billboard Number 1's/Songs/4.mp3
Normal file
Binary file not shown.
BIN
src/Data/Jeff/Billboard Number 1's/Songs/5.mp3
Normal file
BIN
src/Data/Jeff/Billboard Number 1's/Songs/5.mp3
Normal file
Binary file not shown.
101
src/dataLoader.ts
Normal file
101
src/dataLoader.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
type Clue = {
|
||||||
|
number: number
|
||||||
|
points: number
|
||||||
|
song: string | null
|
||||||
|
answer: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type Category = {
|
||||||
|
name: string
|
||||||
|
songs: Record<number, string>
|
||||||
|
answers: Record<number, string>
|
||||||
|
clues: Clue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Game = {
|
||||||
|
name: string
|
||||||
|
categories: Category[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameMap = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
name: string
|
||||||
|
categories: Record<string, Category>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
const songFiles = import.meta.globEager('./Data/*/*/Songs/*.mp3') as Record<
|
||||||
|
string,
|
||||||
|
{ default: string }
|
||||||
|
>
|
||||||
|
|
||||||
|
const answerFiles = import.meta.globEager('./Data/*/*/Answers/*.mp3') as Record<
|
||||||
|
string,
|
||||||
|
{ default: string }
|
||||||
|
>
|
||||||
|
|
||||||
|
const getParts = (path: string) => {
|
||||||
|
const normalized = path.replace(/\\/g, '/')
|
||||||
|
const parts = normalized.split('/')
|
||||||
|
const dataIndex = parts.indexOf('Data')
|
||||||
|
if (dataIndex === -1) return null
|
||||||
|
const game = parts[dataIndex + 1]
|
||||||
|
const category = parts[dataIndex + 2]
|
||||||
|
const type = parts[dataIndex + 3]
|
||||||
|
const file = parts[dataIndex + 4]
|
||||||
|
return { game, category, type, file }
|
||||||
|
}
|
||||||
|
|
||||||
|
const addEntry = (target: GameMap, info: ReturnType<typeof getParts>, url: string) => {
|
||||||
|
if (!info) return
|
||||||
|
const { game, category, type, file } = info
|
||||||
|
const number = Number(file.replace('.mp3', ''))
|
||||||
|
if (!Number.isFinite(number)) return
|
||||||
|
|
||||||
|
if (!target[game]) {
|
||||||
|
target[game] = {
|
||||||
|
name: game,
|
||||||
|
categories: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!target[game].categories[category]) {
|
||||||
|
target[game].categories[category] = {
|
||||||
|
name: category,
|
||||||
|
songs: {},
|
||||||
|
answers: {},
|
||||||
|
clues: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bucket = type === 'Songs' ? 'songs' : 'answers'
|
||||||
|
target[game].categories[category][bucket][number] = url
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildGameData = (): Game[] => {
|
||||||
|
const games: GameMap = {}
|
||||||
|
Object.entries(songFiles).forEach(([path, module]) => {
|
||||||
|
addEntry(games, getParts(path), module.default)
|
||||||
|
})
|
||||||
|
Object.entries(answerFiles).forEach(([path, module]) => {
|
||||||
|
addEntry(games, getParts(path), module.default)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Object.values(games).map((game) => {
|
||||||
|
const categories = Object.values(game.categories).map((category) => {
|
||||||
|
const clues = Array.from({ length: 5 }, (_, index) => {
|
||||||
|
const number = index + 1
|
||||||
|
return {
|
||||||
|
number,
|
||||||
|
points: number * 100,
|
||||||
|
song: category.songs[number] || null,
|
||||||
|
answer: category.answers[number] || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { ...category, clues }
|
||||||
|
})
|
||||||
|
return { ...game, categories }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gameData = buildGameData()
|
||||||
|
export type { Game, Category, Clue }
|
||||||
1
src/env.d.ts
vendored
Normal file
1
src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
5
src/main.ts
Normal file
5
src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
422
src/styles.css
Normal file
422
src/styles.css
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
:root {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
color: #f6f4ef;
|
||||||
|
background: #0d0f1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: radial-gradient(circle at top, #1f2a44, #0b0f1f 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 32px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Bebas Neue', sans-serif;
|
||||||
|
font-size: clamp(2.2rem, 2.5vw, 3rem);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #9fc4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-pill {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-pill .label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
color: #9fc4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-pill .value {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: rgba(8, 12, 26, 0.75);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 32px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-family: 'Bebas Neue', sans-serif;
|
||||||
|
font-size: 2rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-grid,
|
||||||
|
.games-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card,
|
||||||
|
.game-card,
|
||||||
|
.add-card {
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card.winner {
|
||||||
|
outline: 2px solid #fbd72b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-preview {
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 14px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-row label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='color'] {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-card {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
min-height: 140px;
|
||||||
|
background: linear-gradient(135deg, rgba(61, 214, 255, 0.12), rgba(251, 215, 43, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: linear-gradient(135deg, #3dd6ff, #fbd72b);
|
||||||
|
color: #0d0f1c;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: inherit;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost:disabled,
|
||||||
|
.primary:disabled,
|
||||||
|
.team-score-card:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(240px, 300px) 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard {
|
||||||
|
background: rgba(8, 12, 26, 0.8);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-family: 'Bebas Neue', sans-serif;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score-card {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
color: #0d0f1c;
|
||||||
|
text-align: left;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score-card.active {
|
||||||
|
outline: 2px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.award-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-column {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-title {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
background: #1d3b8b;
|
||||||
|
color: #fbd72b;
|
||||||
|
border: none;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 72px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.playing {
|
||||||
|
background: #fbd72b;
|
||||||
|
color: #0d0f1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.paused {
|
||||||
|
background: #f58b2b;
|
||||||
|
color: #0d0f1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.guessed {
|
||||||
|
background: #2bdc7b;
|
||||||
|
color: #0d0f1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.won {
|
||||||
|
background: #2a2f3f;
|
||||||
|
color: #0d0f1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.void {
|
||||||
|
background: #6b6f7b;
|
||||||
|
color: #0d0f1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-panel {
|
||||||
|
background: rgba(8, 12, 26, 0.75);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-header h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-body {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
audio {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 320px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: #000;
|
||||||
|
position: relative;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-empty {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-panel {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.winner-banner {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.game-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2020", "DOM"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
10
vite.config.ts
Normal file
10
vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
assetsInclude: ['**/*.mp3'],
|
||||||
|
server: {
|
||||||
|
port: 5173
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user