This commit is contained in:
Johnny322
2026-02-08 11:36:29 +01:00
commit 2c6cfd934b
22 changed files with 2565 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

18
index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View 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
View 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/&lt;GameName&gt;/&lt;Category&gt;</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
View File

@@ -0,0 +1 @@

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

101
src/dataLoader.ts Normal file
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

5
src/main.ts Normal file
View 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
View 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
View 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
View 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
}
})